diff --git a/.machine_readable/6a2/STATE.a2ml b/.machine_readable/6a2/STATE.a2ml index 3e5968a..9006b5c 100644 --- a/.machine_readable/6a2/STATE.a2ml +++ b/.machine_readable/6a2/STATE.a2ml @@ -110,7 +110,7 @@ open-failures = 0 # with ALPN "burble-bolt-v1" for sender authentication (ADR-0003, ADR-0004). # New modules Burble.Bolt.{Listener,Sender,Quic}; scripts/gen-bolt-cert.sh # for cert provisioning; docs/developer/wsl-mirrored-networking.adoc -# documents WSL mirrored-networking prereq for receiving LAN packets. +# documents WSL inbound-UDP options for the listener (superseded 2026-05-19). # Other features in window: coturn STUN/TURN relay + time-limited ICE # credentials (b10283b), Mumble bridge — permissions, positional audio, # UDP fix (d557742), Assist API + LLM diagnostics + event stream (7fc1a45), @@ -119,6 +119,14 @@ open-failures = 0 # automated backups (acbe88a), AffineScript Canary CI workflow (8eeed52), # brand pack (ba23ec1). Android client + cross-repo Bluetooth plan landed # as architecture doc (b1070af) — implementation deferred. +# 2026-05-19: WSL Bolt inbound networking reworked — mirrored mode caused +# recurring Hyper-V VmSwitch port-restore failures + intermittent +# Wsl/Service/E_UNEXPECTED on Win11 24H2/Insider. New +# scripts/wsl-bolt-udp-forward.ps1 host UDP forwarder (default NAT, +# bidirectional, udp/7373+9); wsl-mirrored-networking.adoc rewritten +# (NAT+forwarder default, bridged alt, mirrored last-resort); ADR-0005. +# proven-stun NAT traversal scoped + rejected (cold bolts target +# offline peers — hole-punching structurally impossible). [crg] grade = "C" diff --git a/CHANGELOG.md b/CHANGELOG.md index abb9c11..94d50b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,9 +15,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Safe `pollEvents` returning `Maybe NifEvent` instead of raw `Bits64` - Bidirectional PositionUpdate (tag 8) decode with Vec3 + orientation relay via PubSub - SpeakingStart/Stop (tags 6-7) diagnostic decode with server-only enforcement +- `scripts/wsl-bolt-udp-forward.ps1` — host UDP forwarder for the WSL2 Bolt listener (default NAT, no mirrored networking; ADR-0005) ### Changed - `prim__registerCallback` made module-private (unsafe boundary, awaits idris2#3182) +- `docs/developer/wsl-mirrored-networking.adoc` rewritten — NAT + host forwarder is the recommended WSL2 Bolt path; mirrored networking demoted to last-resort (Win11 24H2/Insider `Wsl/Service/E_UNEXPECTED` instability) ### Removed - TODO.md (superseded by CLAUDE-WORK.md — 0 TODOs remain in codebase) diff --git a/CLAUDE.md b/CLAUDE.md index 985a8f7..2d030d3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -113,8 +113,8 @@ The Bolt listener binds udp/7373 (`Burble.Bolt.Listener`, also QUIC via default NAT mode, inbound LAN UDP never reaches the listener. If you're on Windows + WSL2 and the server side won't accept Bolt datagrams from another host, see `docs/developer/wsl-mirrored-networking.adoc` — -`networkingMode=mirrored` in the host `.wslconfig` plus a Defender -firewall rule for udp/7373. +default NAT + the `scripts/wsl-bolt-udp-forward.ps1` host forwarder +(recommended) plus a Defender firewall rule for udp/7373. ## Do not diff --git a/docs/decisions/0005-wsl-bolt-inbound-udp-no-mirrored.adoc b/docs/decisions/0005-wsl-bolt-inbound-udp-no-mirrored.adoc new file mode 100644 index 0000000..8657e91 --- /dev/null +++ b/docs/decisions/0005-wsl-bolt-inbound-udp-no-mirrored.adoc @@ -0,0 +1,100 @@ += Architecture Decision Record: 0005-wsl-bolt-inbound-udp-no-mirrored + + + +# 5. WSL2 Bolt inbound UDP via a host forwarder, not mirrored networking + +Date: 2026-05-19 + +## Status + +Accepted + +Relates to ADR-0004 (Bolt QUIC dual-bind — same udp/7373). + +## Context + +`Burble.Bolt.Listener` binds udp/7373 (and udp/9 WoL-compat), optionally +QUIC on the same port (ADR-0004). When the server runs inside WSL2 — the +default developer environment on Windows — its default NAT networking +drops inbound LAN UDP at the host: the distro sits behind an internal +vEthernet adapter and only outbound flows + replies are forwarded. Cold +bolts and cross-host QUIC bolts never reach the listener. + +`docs/developer/wsl-mirrored-networking.adoc` previously instructed every +contributor to set `networkingMode=mirrored` in `%USERPROFILE%\.wslconfig`, +together with `dnsTunneling=true`, `autoProxy=true`, and experimental +`hostAddressLoopback=true`. + +That guidance is actively harmful on current Windows. On Windows 11 24H2 +and Insider builds, mirrored mode plus those companions causes recurring +Hyper-V VmSwitch `Failed to restore configuration for port … Object Name +not found` errors and intermittent `Wsl/Service/E_UNEXPECTED` +catastrophic failures — the WSL vNIC flaps and the utility VM bounces +every few hours. The development environment becomes, in the words of the +report that triggered this ADR, "incredibly unstable". Anyone following +the documented setup inherits the instability. + +Constraints on the fix: + +* `netsh interface portproxy` is TCP-only; it cannot forward UDP, so the + classic port-forward shortcut is unavailable. +* Bolt's primary path is stateless fire-and-forget raw UDP (cold poke), + but the QUIC path (ADR-0004) needs the reply datagram to return to the + original sender, so a one-directional forward is insufficient. +* The WSL2 NAT IP changes on every distro boot. + +STUN/ICE NAT traversal (`proven-servers` `proven-stun`) was considered +and rejected: a cold bolt by design wakes a recipient who is *not* in any +session and may be offline, so hole-punching — which needs both peers +coordinating through a rendezvous simultaneously — cannot serve it. Bolt +is deliberately a server-reachability design (NAPTR/SRV + DDNS + router +port-forward), not symmetric P2P. + +## Decision + +Stop requiring mirrored networking. The recommended path is **default +WSL2 NAT plus a userspace UDP forwarder on the Windows host**: + +* `scripts/wsl-bolt-udp-forward.ps1` — a bidirectional UDP relay binding + udp/7373 + udp/9 on the host, with a per-client ephemeral upstream + socket and idle expiry so QUIC handshakes and acks return to the + original sender. It re-resolves the WSL NAT IP every 15 s and rebuilds + upstream sockets when the distro reboots onto a new address. `-Install` + registers a logon scheduled task; `-Firewall` (elevated) adds the + Defender allow rules. +* Bridged networking (`networkingMode=bridged` + an external vSwitch) is + documented as a forwarder-free alternative for wired workstations. +* Mirrored networking is demoted to fallback-of-last-resort, retained + only for older stable Windows builds, with an explicit 24H2/Insider + instability warning, and pared to `networkingMode=mirrored` + + `firewall=true` (the crash-implicated companions removed). + +`docs/developer/wsl-mirrored-networking.adoc` is rewritten accordingly; +its filename is deliberately kept (five inbound references, including +machine-readable state, made a rename pure breakage risk). + +## Consequences + +### Positive + +* The default contributor dev environment is stable on every supported + Windows build — the instability class is removed at the source. +* The forwarder is portable: no Insider-specific config, works on Wi-Fi + and Ethernet, no admin needed for the relay itself. +* Bidirectional relay keeps the ADR-0004 QUIC return path working. + +### Negative + +* An extra Windows-host moving part (a scheduled task + a long-running + relay process) that contributors must install. +* A stateless localhost-adjacent hop is added to the cold-poke path + (negligible latency, but it exists). + +### Neutral + +* The on-machine `~/.wslconfig` revert from mirrored to plain NAT is an + operational step, tracked outside the repo; it is gated on a + reachability check (`scripts/check-bolt-reachability.sh`). +* `proven-frame` (verified framing of `Burble.Bolt.Packet`) remains a + separate, low-priority hardening opportunity, out of scope here. diff --git a/docs/decisions/README.adoc b/docs/decisions/README.adoc index 153a5e7..b08ae74 100644 --- a/docs/decisions/README.adoc +++ b/docs/decisions/README.adoc @@ -1 +1,34 @@ -= decisions Unit +// SPDX-License-Identifier: PMPL-1.0-or-later += Architecture Decision Records + +This is Burble's decision log. Each ADR captures one significant +architectural decision, its context, and its consequences. ADRs are +immutable once Accepted; a later ADR supersedes an earlier one rather +than editing it. + +New ADRs copy `0000-template.adoc` and take the next number. + +[cols="1,3,1",options="header"] +|=== +| ADR | Title | Status + +| link:0001-adopt-rsr-standard.adoc[0001] +| Adopt the RSR standard +| Accepted + +| link:0002-indieweb-native-optional-integration.adoc[0002] +| IndieWeb-native optional integration +| Accepted + +| link:0003-pake-sas-tiered-auth.adoc[0003] +| PAKE/SAS tiered authentication +| Accepted + +| link:0004-bolt-quic-dual-bind.adoc[0004] +| Bolt QUIC dual-bind alongside raw UDP +| Accepted + +| link:0005-wsl-bolt-inbound-udp-no-mirrored.adoc[0005] +| WSL2 Bolt inbound UDP via a host forwarder, not mirrored networking +| Accepted +|=== diff --git a/docs/developer/README.adoc b/docs/developer/README.adoc index 74937d3..f840b5e 100644 --- a/docs/developer/README.adoc +++ b/docs/developer/README.adoc @@ -6,7 +6,8 @@ Developer-facing guides for working on Burble locally. * link:ABI-FFI-README.adoc[ABI-FFI-README] — Zig FFI surface + Idris2 validations the Zig side mirrors. -* link:wsl-mirrored-networking.adoc[wsl-mirrored-networking] — host - `.wslconfig` setup for inbound UDP on the Bolt listener (udp/7373) - when running the server side under WSL2. Read this before debugging - "Bolt datagrams aren't arriving" on a Windows dev host. +* link:wsl-mirrored-networking.adoc[wsl-mirrored-networking] — getting + inbound UDP to the Bolt listener (udp/7373) when the server runs + under WSL2: default NAT + the host UDP forwarder (recommended), + bridged, or mirrored. Read this before debugging "Bolt datagrams + aren't arriving" on a Windows dev host. diff --git a/docs/developer/bolt-ddns.adoc b/docs/developer/bolt-ddns.adoc index df0b454..09777d8 100644 --- a/docs/developer/bolt-ddns.adoc +++ b/docs/developer/bolt-ddns.adoc @@ -121,6 +121,6 @@ same providers and writes an AAAA record. * link:bolt-dns-records.adoc[bolt-dns-records.adoc] — NAPTR/SRV records that depend on this hostname resolving to the current host -* link:wsl-mirrored-networking.adoc[wsl-mirrored-networking.adoc] — the - WSL2 networking mode that makes UDP/7373 reachable on the host LAN IP +* link:wsl-mirrored-networking.adoc[wsl-mirrored-networking.adoc] — how + UDP/7373 reaches the WSL listener on the host LAN IP (NAT forwarder) * `scripts/cf-bolt-dns.sh` — the NAPTR/SRV provisioner diff --git a/docs/developer/router-port-forward.adoc b/docs/developer/router-port-forward.adoc index 5924930..877cd07 100644 --- a/docs/developer/router-port-forward.adoc +++ b/docs/developer/router-port-forward.adoc @@ -202,6 +202,6 @@ task #13's end-to-end test from Joshua's machine. record current so NAPTR resolves to the host even as the WAN IP drifts * link:bolt-dns-records.adoc[bolt-dns-records.adoc] — the NAPTR/SRV records that announce this host as Bolt-reachable -* link:wsl-mirrored-networking.adoc[wsl-mirrored-networking.adoc] — why - the LAN IP is the WSL listener's address (no second host→WSL forward) +* link:wsl-mirrored-networking.adoc[wsl-mirrored-networking.adoc] — how + the host LAN IP reaches the WSL listener (NAT + host UDP forwarder) * `scripts/check-bolt-reachability.sh` — verification helper diff --git a/docs/developer/wsl-mirrored-networking.adoc b/docs/developer/wsl-mirrored-networking.adoc index d6ffa60..2b09b7f 100644 --- a/docs/developer/wsl-mirrored-networking.adoc +++ b/docs/developer/wsl-mirrored-networking.adoc @@ -1,119 +1,161 @@ -= WSL2 Mirrored Networking for Burble Bolt +// SPDX-License-Identifier: PMPL-1.0-or-later += WSL2 Inbound UDP for Burble Bolt :toc: macro :icons: font toc::[] -== Why +[NOTE] +==== +This document used to recommend WSL2 *mirrored networking*. It no longer +does. Mirrored mode is now the *fallback of last resort* — see +<>. The default, portable, hazard-free path is NAT + the host +UDP forwarder below. The filename is kept for stable inbound links. +==== -The Bolt listener (`Burble.Bolt.Listener`) binds UDP/QUIC on port 7373. -Under WSL2's default NAT mode, inbound UDP from the Windows LAN is dropped -at the host: the WSL2 distro lives behind an internal vEthernet adapter -(`172.x.0.0/16`) and only outbound flows + their reply packets are -forwarded. Wake-on-bolt and cross-host QUIC bolts (`udp/7373`, -`udp/9` for the WoL-compat fallback) therefore never reach the listener -when the server is run inside WSL2. +== The problem -Mirrored mode (`networkingMode=mirrored`, Windows 11 22H2+ / WSL 2.0+) -makes the WSL2 instance share the Windows host's network interfaces — -listeners in WSL appear on the host's LAN IP directly, no portproxy -required. +The Bolt listener (`Burble.Bolt.Listener`) binds UDP on port 7373 +(`Packet.port/0`) and udp/9 (`Packet.wol_port/0`, WoL-compat), with an +optional QUIC listener sharing 7373. Under WSL2's *default NAT* mode the +distro lives behind an internal vEthernet adapter (`172.x/16`); only +outbound flows and their replies are forwarded. Inbound UDP arriving on +the Windows host's LAN IP — cold bolts, cross-host QUIC bolts — never +reaches the listener when the server runs inside WSL2. -== Host configuration +`netsh interface portproxy` does **not** solve this: portproxy is +TCP-only and cannot forward UDP. So the inbound path must be replaced +some other way. -`%USERPROFILE%\.wslconfig` on the Windows host: +== Recommended: default NAT + host UDP forwarder + +Keep WSL on its default networking. Run a small userspace UDP relay on +the Windows host that binds udp/7373 + udp/9 and forwards to the current +WSL distro IP, re-resolving the IP on each boot. This works on every +Windows build, wired or wireless, with no `.wslconfig` networking +changes and none of the mirrored-mode instability. + +[source,powershell] +---- +# One-time: register a logon scheduled task (optionally add firewall rules +# from an elevated shell): +.\scripts\wsl-bolt-udp-forward.ps1 -Install +.\scripts\wsl-bolt-udp-forward.ps1 -Install -Firewall # elevated + +# Inspect / run in foreground / remove: +.\scripts\wsl-bolt-udp-forward.ps1 -Status +.\scripts\wsl-bolt-udp-forward.ps1 -Run +.\scripts\wsl-bolt-udp-forward.ps1 -Uninstall +---- + +The relay is bidirectional (per-client ephemeral upstream socket, 30 s +idle expiry), so QUIC handshakes and ack datagrams return to the original +sender — not only fire-and-forget cold pokes. It re-resolves the WSL IP +every 15 s and rebuilds upstream sockets if the distro rebooted onto a +new NAT address. See `scripts/wsl-bolt-udp-forward.ps1` for the full +contract. + +[NOTE] +Defender Firewall must still allow inbound udp/7373 (+udp/9). Either run +`-Install -Firewall` elevated, or add the rule manually — see +<>. + +== Alternative: bridged networking (wired hosts) + +On a wired workstation where an external Hyper-V switch is acceptable, +`networkingMode=bridged` gives the WSL distro a real LAN IP, so the +listener is directly reachable with no forwarder. This is forwarder-free +but needs a per-host external vSwitch (admin) and is unreliable on Wi-Fi +adapters, so it is an alternative, not the default. [source,ini] ---- [wsl2] -networkingMode=mirrored -firewall=true -dnsTunneling=true -autoProxy=true - -[experimental] -hostAddressLoopback=true +networkingMode=bridged +vmSwitch= +# macAddress= # optional, for DHCP reservations ---- -* `firewall=true` — WSL auto-creates inbound allow rules for ports - bound by WSL processes. Without this, Windows Defender Firewall - silently drops Bolt datagrams even with mirrored mode on. -* `hostAddressLoopback=true` — lets WSL processes reach services on - the Windows host via `127.0.0.1` (the inverse of what mirrored mode - fixes for inbound). +[[mirrored]] +== Fallback of last resort: mirrored networking -Restart the WSL VM for changes to take effect: +[WARNING] +==== +Mirrored mode (`networkingMode=mirrored`) plus its usual companions +(`dnsTunneling`, `autoProxy`, experimental `hostAddressLoopback`) causes +recurring `Failed to restore configuration for port … Object Name not +found` Hyper-V VmSwitch errors and intermittent +`Wsl/Service/E_UNEXPECTED` catastrophic failures (vNIC flap / VM bounce) +on Windows 11 24H2 and Insider builds. Do **not** use it there. Prefer +the forwarder above. +==== -[source,powershell] +If you are on an older, stable Windows 11 build where mirrored mode is +known good for your setup, the minimal viable config is mirrored + +firewall only (drop the crash-implicated companions): + +[source,ini] ---- -wsl --shutdown +[wsl2] +networkingMode=mirrored +firewall=true ---- -The next `wsl` invocation cold-boots the VM into mirrored mode. +Restart the VM for `.wslconfig` changes to take effect: `wsl --shutdown`. +[[firewall]] == Firewall rule for udp/7373 -`firewall=true` covers the common case, but if Windows Defender Firewall -is centrally managed (group policy, third-party firewall, locked-down -profile) the auto-rule may be suppressed. Add an explicit allow rule: +`firewall=true` (mirrored) or `-Install -Firewall` (forwarder) covers the +common case. If Defender Firewall is centrally managed and the auto-rule +is suppressed, add it explicitly (elevated): [source,powershell] ---- -New-NetFirewallRule ` - -DisplayName "Burble Bolt (WSL2 mirrored)" ` - -Direction Inbound ` - -Protocol UDP ` - -LocalPort 7373 ` - -Action Allow ` - -Profile Private,Domain +New-NetFirewallRule -DisplayName "Burble Bolt udp/7373" ` + -Direction Inbound -Protocol UDP -LocalPort 7373 ` + -Action Allow -Profile Private,Domain +# Add a second rule for udp/9 if the WoL-compat fallback is in use. ---- -Add port 9 as a second rule if the WoL-compat fallback is in use. - == Verification -From inside WSL, start the Bolt listener and confirm it is reachable on -the host's LAN IP — not just `127.0.0.1`: +From inside WSL, start the listener; from another LAN host, confirm the +datagram arrives: [source,bash] ---- # In WSL: -just server # boots Burble.Bolt.Listener on udp/7373 +just server # boots Burble.Bolt.Listener on udp/7373 -# In Windows PowerShell (or another LAN host): -Test-NetConnection -ComputerName -Port 7373 -InformationLevel Detailed -# Expect: UDP probe — mirrored mode means the WSL listener owns the port. ----- - -For an end-to-end sanity check, fire a raw datagram from another machine -on the LAN and look for the receive log line in the listener: - -[source,bash] ----- -echo -n "ping" | nc -u -w1 7373 +# From another LAN host: +echo -n "ping" | nc -u -w1 7373 # Listener should log: [Bolt] received datagram from {ip, port} + +# End-to-end (public path), from the repo: +./scripts/check-bolt-reachability.sh ---- == Gotchas -* **Hyper-V firewall**: a separate layer from Defender. If inbound - packets still vanish after the rule above, see the host-side note in - `~/.claude/projects/.../memory/reference_wsl_network_workaround.md` +* *Hyper-V firewall* is a separate layer from Defender. If packets still + vanish, see the host-side note in + `~/.claude/.../memory/reference_wsl_network_workaround.md` (`Set-NetFirewallHyperVVMSetting -Enabled $false`, requires admin). -* **`localhost` is now host-shared**: services bound to `127.0.0.1` in - WSL are reachable from Windows on `127.0.0.1` and vice versa. Treat - loopback as cross-OS — don't bind sensitive dev tooling to `0.0.0.0` - expecting NAT to hide it. -* **VPN clients**: some corp VPNs (Cisco AnyConnect, GlobalProtect) - refuse to forward to the mirrored adapter. If the listener stops - receiving when the VPN is connected, that's why. -* **`localhostForwarding`**: the legacy option is ignored under mirrored - mode. Removing it from `.wslconfig` is cosmetic but avoids confusion. +* *VPN clients* (Cisco AnyConnect, GlobalProtect) may refuse to forward + to the WSL adapter; the relay or mirrored listener goes silent while + the VPN is connected. +* *WSL IP churn*: under NAT the distro IP changes per boot — this is + expected and the forwarder re-resolves it; do not hardcode it. == Related +* `scripts/wsl-bolt-udp-forward.ps1` — the host UDP forwarder (this is + the recommended path). * `server/lib/burble/bolt/listener.ex` — binds udp/7373 + optional QUIC. * `server/lib/burble/bolt/quic.ex` — QUIC datagram listener (ALPN `burble-bolt-v1`), shares the same UDP port. * `server/lib/burble/bolt/packet.ex` — `Packet.port/0` (7373), `Packet.wol_port/0` (9). +* link:router-port-forward.adoc[router-port-forward.adoc] — getting + udp/7373 from the public internet to this host. diff --git a/scripts/wsl-bolt-udp-forward.ps1 b/scripts/wsl-bolt-udp-forward.ps1 new file mode 100755 index 0000000..8107332 --- /dev/null +++ b/scripts/wsl-bolt-udp-forward.ps1 @@ -0,0 +1,238 @@ +# SPDX-License-Identifier: PMPL-1.0-or-later +# +# wsl-bolt-udp-forward.ps1 - forward inbound Bolt UDP from the Windows host +# into a WSL2 distro running the Burble server, WITHOUT WSL2 mirrored +# networking. +# +# Why this exists: +# Under WSL2's default NAT mode the distro sits behind an internal +# vEthernet adapter, so inbound UDP arriving on the Windows host LAN IP +# never reaches Burble.Bolt.Listener (udp/7373, plus udp/9 WoL-compat). +# The historical fix was networkingMode=mirrored, but mirrored mode plus +# its companions (dnsTunneling/autoProxy/hostAddressLoopback) causes +# recurring Wsl/Service/E_UNEXPECTED catastrophic failures and vNIC flap +# on Windows 11 24H2 / Insider builds. This forwarder removes the need +# for mirrored mode entirely: keep WSL on default NAT and relay the two +# Bolt UDP ports at the host. netsh portproxy is TCP-only and cannot be +# used for this; this is a real userspace UDP relay. +# +# It is a bidirectional relay (per-client ephemeral upstream socket, idle +# expiry) so QUIC/ack return datagrams route back to the original sender, +# not just fire-and-forget cold pokes. The WSL NAT IP changes per boot, so +# the target is re-resolved periodically and sockets rebuilt on change. +# +# Usage: +# .\wsl-bolt-udp-forward.ps1 -Run # run the relay (foreground) +# .\wsl-bolt-udp-forward.ps1 -Install # register a logon scheduled task +# .\wsl-bolt-udp-forward.ps1 -Uninstall # remove the scheduled task +# .\wsl-bolt-udp-forward.ps1 -Status # show resolved IP + task state +# +# Options: +# -Distro WSL distro (default: the WSL default distribution) +# -Ports UDP ports to relay (default: 7373,9) +# -Firewall With -Install, also add inbound Defender allow rules +# (requires an elevated shell; skipped with a warning if +# not elevated) +# +# Exit: +# 0 - clean shutdown (Ctrl-C) / action completed +# 1 - bad arguments +# 2 - a listen socket could not bind (port already in use?) +# 3 - WSL distro never became resolvable + +[CmdletBinding(DefaultParameterSetName = 'Run')] +param( + [Parameter(ParameterSetName = 'Run')] [switch]$Run, + [Parameter(ParameterSetName = 'Install')] [switch]$Install, + [Parameter(ParameterSetName = 'Uninstall')][switch]$Uninstall, + [Parameter(ParameterSetName = 'Status')] [switch]$Status, + [string]$Distro, + [int[]]$Ports = @(7373, 9), + [switch]$Firewall +) + +$ErrorActionPreference = 'Stop' +$script:TaskName = 'BurbleBoltUdpForward' + +function Resolve-WslIp { + param([string]$Distro) + $wslArgs = @() + if ($Distro) { $wslArgs += @('-d', $Distro) } + $wslArgs += @('--', 'hostname', '-I') + try { + $out = (& wsl.exe @wslArgs) 2>$null + } catch { return $null } + if (-not $out) { return $null } + foreach ($tok in ($out -split '\s+')) { + # First IPv4 that is not loopback. + if ($tok -match '^\d{1,3}(\.\d{1,3}){3}$' -and $tok -ne '127.0.0.1') { + return $tok + } + } + return $null +} + +function Wait-WslIp { + param([string]$Distro, [int]$TimeoutSec = 90) + $deadline = (Get-Date).AddSeconds($TimeoutSec) + do { + $ip = Resolve-WslIp -Distro $Distro + if ($ip) { return $ip } + Start-Sleep -Seconds 3 + } while ((Get-Date) -lt $deadline) + return $null +} + +function Invoke-Relay { + param([string]$Distro, [int[]]$Ports) + + Add-Type -AssemblyName System.Net | Out-Null + $wslIp = Wait-WslIp -Distro $Distro + if (-not $wslIp) { + Write-Error "WSL distro never became resolvable (hostname -I empty)." + exit 3 + } + Write-Host "[bolt-fwd] WSL target: $wslIp ; relaying udp/$($Ports -join ',')" + + $listeners = @{} # port -> Socket bound on 0.0.0.0:port + foreach ($p in $Ports) { + $s = New-Object System.Net.Sockets.Socket( + [System.Net.Sockets.AddressFamily]::InterNetwork, + [System.Net.Sockets.SocketType]::Dgram, + [System.Net.Sockets.ProtocolType]::Udp) + try { + $s.Bind((New-Object System.Net.IPEndPoint([System.Net.IPAddress]::Any, $p))) + } catch { + Write-Error "Could not bind udp/${p}: $($_.Exception.Message)" + exit 2 + } + $listeners[$p] = $s + } + + # Per (port + client) ephemeral upstream socket toward WSL. + # key = "port|clientIp:clientPort" -> @{ Sock; Client; Port; Last } + $ups = @{} + $buf = New-Object byte[] 65535 + $lastResolve = Get-Date + + Write-Host "[bolt-fwd] running. Ctrl-C to stop." + while ($true) { + # Re-resolve the WSL IP every 15s; rebuild upstreams on change. + if (((Get-Date) - $lastResolve).TotalSeconds -ge 15) { + $lastResolve = Get-Date + $cur = Resolve-WslIp -Distro $Distro + if ($cur -and $cur -ne $wslIp) { + Write-Host "[bolt-fwd] WSL IP changed $wslIp -> $cur ; resetting upstreams" + $wslIp = $cur + foreach ($u in $ups.Values) { $u.Sock.Close() } + $ups.Clear() + } + } + + $readable = New-Object System.Collections.ArrayList + foreach ($s in $listeners.Values) { [void]$readable.Add($s) } + foreach ($u in $ups.Values) { [void]$readable.Add($u.Sock) } + [System.Net.Sockets.Socket]::Select($readable, $null, $null, 500000) # 0.5s + + foreach ($sock in $readable) { + $remote = [System.Net.EndPoint](New-Object System.Net.IPEndPoint([System.Net.IPAddress]::Any, 0)) + try { $n = $sock.ReceiveFrom($buf, [ref]$remote) } catch { continue } + if ($n -le 0) { continue } + $payload = $buf[0..($n - 1)] + + $listenPort = $null + foreach ($kv in $listeners.GetEnumerator()) { + if ($kv.Value -eq $sock) { $listenPort = $kv.Key; break } + } + + if ($null -ne $listenPort) { + # Inbound from a LAN client -> forward to WSL:listenPort. + $key = "$listenPort|$($remote.ToString())" + $u = $ups[$key] + if (-not $u) { + $usock = New-Object System.Net.Sockets.Socket( + [System.Net.Sockets.AddressFamily]::InterNetwork, + [System.Net.Sockets.SocketType]::Dgram, + [System.Net.Sockets.ProtocolType]::Udp) + $usock.Connect($wslIp, $listenPort) + $u = @{ Sock = $usock; Client = $remote; LPort = $listenPort; Last = (Get-Date) } + $ups[$key] = $u + } + $u.Last = Get-Date + [void]$u.Sock.Send($payload, $n, [System.Net.Sockets.SocketFlags]::None) + } else { + # Reply from WSL on an upstream socket -> back to its client. + foreach ($kv in $ups.GetEnumerator()) { + if ($kv.Value.Sock -eq $sock) { + $u = $kv.Value + [void]$listeners[$u.LPort].SendTo( + $payload, $n, [System.Net.Sockets.SocketFlags]::None, $u.Client) + $u.Last = Get-Date + break + } + } + } + } + + # Idle-expire upstream sockets (> 30s silent). + $dead = @() + foreach ($kv in $ups.GetEnumerator()) { + if (((Get-Date) - $kv.Value.Last).TotalSeconds -gt 30) { $dead += $kv.Key } + } + foreach ($k in $dead) { $ups[$k].Sock.Close(); $ups.Remove($k) } + } +} + +function Install-Task { + param([string]$Distro, [int[]]$Ports, [switch]$Firewall) + $self = $MyInvocation.MyCommand.Path + if (-not $self) { $self = $PSCommandPath } + # NOTE: use -Command, not -File. With -File, `-Ports 7373,9` is passed + # as the literal string "7373,9" which cannot bind to [int[]]$Ports and + # the task aborts before binding (LastTaskResult=1). -Command parses the + # array argument correctly. + $inner = "& '$self' -Run" + if ($Distro) { $inner += " -Distro '$Distro'" } + if ($Ports) { $inner += " -Ports $($Ports -join ',')" } + $argline = "-NoProfile -ExecutionPolicy Bypass -Command `"$inner`"" + + $action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument $argline + $trigger = New-ScheduledTaskTrigger -AtLogOn + $set = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries ` + -DontStopIfGoingOnBatteries -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) + Register-ScheduledTask -TaskName $script:TaskName -Action $action -Trigger $trigger ` + -Settings $set -Description 'Burble Bolt UDP forwarder (WSL2 NAT, no mirrored networking)' -Force | Out-Null + Write-Host "[bolt-fwd] scheduled task '$($script:TaskName)' registered (runs at logon)." + + if ($Firewall) { + $elevated = ([Security.Principal.WindowsPrincipal] ` + [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole( + [Security.Principal.WindowsBuiltinRole]::Administrator) + if (-not $elevated) { + Write-Warning "-Firewall needs an elevated shell; skipping firewall rules. Re-run elevated or add them manually." + } else { + foreach ($p in $Ports) { + New-NetFirewallRule -DisplayName "Burble Bolt (WSL2 NAT fwd) udp/$p" ` + -Direction Inbound -Protocol UDP -LocalPort $p -Action Allow ` + -Profile Private,Domain -ErrorAction SilentlyContinue | Out-Null + } + Write-Host "[bolt-fwd] firewall allow rules added for udp/$($Ports -join ',')." + } + } +} + +switch ($PSCmdlet.ParameterSetName) { + 'Install' { Install-Task -Distro $Distro -Ports $Ports -Firewall:$Firewall } + 'Uninstall' { + Unregister-ScheduledTask -TaskName $script:TaskName -Confirm:$false -ErrorAction SilentlyContinue + Write-Host "[bolt-fwd] scheduled task removed." + } + 'Status' { + $ip = Resolve-WslIp -Distro $Distro + Write-Host "WSL target IP : $(if ($ip) { $ip } else { '(unresolved - is the distro running?)' })" + $t = Get-ScheduledTask -TaskName $script:TaskName -ErrorAction SilentlyContinue + Write-Host "Scheduled task: $(if ($t) { $t.State } else { 'not installed' })" + Write-Host "Relayed ports : udp/$($Ports -join ',')" + } + default { Invoke-Relay -Distro $Distro -Ports $Ports } +}