Public/Portproxy/Set-RouterSshPortProxy.ps1
|
<# .NOTES Do not run this file directly. Dot-sourced by provision.ps1. #> # --------------------------------------------------------------------------- # Set-RouterSshPortProxy # Ensures a host-side netsh portproxy rule forwarding # <ListenAddress>:<ListenPort> (typically 127.0.0.1:2222) to # <ConnectAddress>:<ConnectPort> (the router VM's SSH endpoint). # Refreshes on every run: deletes any rule already bound to the # listen target and re-adds it - even when the connect target is # unchanged - then adds fresh when none exists. The unconditional # re-add is load-bearing; see the body for why a "skip when # identical" optimisation strands the relay across a reprovision. # # Why this matters: WSL2 runs as a separate Hyper-V guest with its # own NAT subnet. Outbound from WSL to the host's Internal-vSwitch # subnet (e.g. 192.168.137.0/24, the ICS-served network) is not # forwarded by default - ICS NAT is set up for WSL -> Internet via # the WAN, not WSL -> peer VM via the LAN side. A localhost port # proxy on the host turns the cross-subnet hop into a same-host # loopback that WSL can always reach. Ansible's ProxyCommand # (sshpass + ssh routeradmin@<host-port>) then succeeds. # # The rule text persists across reboots AND across VM/switch # teardowns (netsh portproxy state lives in # HKLM\SYSTEM\CurrentControlSet\Services\PortProxy) - which is # precisely why the re-add is unconditional: a persisted rule whose # target router and Internal vSwitch were recreated keeps stale # iphlpsvc forwarding behind unchanged rule text. # --------------------------------------------------------------------------- function Set-RouterSshPortProxy { [CmdletBinding()] param( # Host-side listen target. 0.0.0.0 (all interfaces) is the # default because WSL2 in default NAT mode cannot reach the # host's 127.0.0.1 - from inside WSL, `127.0.0.1` is WSL's # own loopback, NOT the host's. WSL can reach the host on # its WSL-side vEthernet IP, which 0.0.0.0 covers. Operators # who don't need WSL access can pin it back to 127.0.0.1 for # tighter isolation. Windows Firewall still gates inbound on # 2222 - the LAN-facing surface is only as open as the # firewall profile allows. [string] $ListenAddress = '0.0.0.0', [int] $ListenPort = 2222, # Router VM's reachable IP on the host's Internal vSwitch # (typically the routerExternalIp from secret.json). [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $ConnectAddress, [int] $ConnectPort = 22 ) $existing = Get-NetshPortProxyRules | Where-Object { $_.ListenAddress -eq $ListenAddress -and $_.ListenPort -eq $ListenPort } | Select-Object -First 1 if ($existing) { # Delete-and-re-add unconditionally, even when the connect target # is unchanged. The rule text survives a router/switch teardown, # but iphlpsvc binds the forwarding behind it to the network # generation live when the rule was added. E2E (and any # reprovision) destroys and recreates the router VM and its # Internal vSwitch, leaving a rule that LOOKS correct but whose # relay is stale: WSL reaches the listener, the onward hop to the # recreated router never delivers, and Ansible only surfaces an # opaque "Connection timed out during banner exchange" # UNREACHABLE. Re-adding forces iphlpsvc to re-register the # forwarding against the current generation. The former "skip # when the rule is identical" optimisation is what stranded it. Write-Host (" [portproxy] {0}:{1} present (-> {2}:{3}); refreshing via delete + re-add to rebind the relay." -f ` $ListenAddress, $ListenPort, $existing.ConnectAddress, $existing.ConnectPort) & netsh interface portproxy delete v4tov4 ` listenaddress=$ListenAddress listenport=$ListenPort | Out-Null } else { Write-Host (" [portproxy] adding {0}:{1} -> {2}:{3}" -f ` $ListenAddress, $ListenPort, $ConnectAddress, $ConnectPort) } # The add is retry-wrapped. netsh portproxy add can fail transiently # when iphlpsvc is momentarily busy, and because the delete above has # already run, a single hard failure would strand the listen target # with NO rule at all - strictly worse than the stale rule we are # refreshing. A short bounded retry absorbs the transient case; a # genuine failure still throws after the final attempt. netsh signals # failure through its exit code (not an exception), so this uses # Common.PowerShell's Invoke-WithExitCodeRetry (the exit-code sibling # of Invoke-WithRetry): its contract is that the script block's final # statement is the native command whose $LASTEXITCODE drives the loop. Invoke-WithExitCodeRetry ` -OperationName "netsh portproxy add ${ListenAddress}:${ListenPort} -> ${ConnectAddress}:${ConnectPort}" ` -ScriptBlock { & netsh interface portproxy add v4tov4 ` listenaddress=$ListenAddress listenport=$ListenPort ` connectaddress=$ConnectAddress connectport=$ConnectPort | Out-Null } } |