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).
# Idempotent: parses the existing v4tov4 rules and skips when a
# matching rule already exists; deletes-and-re-adds when the
# connect target has changed; adds-fresh when absent.
#
# 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.
#
# Persists across reboots (netsh portproxy state lives in
# HKLM\SYSTEM\CurrentControlSet\Services\PortProxy). Safe to run
# every provisioning attempt because of the idempotency check.
# ---------------------------------------------------------------------------

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) {
        if ($existing.ConnectAddress -eq $ConnectAddress -and
            $existing.ConnectPort    -eq $ConnectPort) {
            Write-Host (" [portproxy] {0}:{1} -> {2}:{3} already present, skipping." -f `
                $ListenAddress, $ListenPort, $ConnectAddress, $ConnectPort)
            return
        }
        Write-Host (" [portproxy] {0}:{1} currently forwards to {2}:{3}; replacing with {4}:{5}." -f `
            $ListenAddress, $ListenPort,
            $existing.ConnectAddress, $existing.ConnectPort,
            $ConnectAddress, $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)
    }

    & netsh interface portproxy add v4tov4 `
        listenaddress=$ListenAddress listenport=$ListenPort `
        connectaddress=$ConnectAddress connectport=$ConnectPort | Out-Null
    if ($LASTEXITCODE -ne 0) {
        throw "netsh interface portproxy add failed with exit $LASTEXITCODE for ${ListenAddress}:${ListenPort} -> ${ConnectAddress}:${ConnectPort}."
    }
}