Public/Firewall/Set-RouterSshPortProxyFirewall.ps1

<#
.NOTES
    Do not run this file directly. Dot-sourced by provision.ps1.
#>


# ---------------------------------------------------------------------------
# Set-RouterSshPortProxyFirewall
# Windows Defender Firewall companion to Set-RouterSshPortProxy.
# Without an inbound allow the portproxy listens on 0.0.0.0:<port>
# but the firewall silently drops inbound TCP from WSL, surfacing
# later as the "Connection timed out during banner exchange" Ansible
# UNREACHABLE.
#
# Scoped by REMOTE ADDRESS (the WSL NAT range), NOT by interface.
# An earlier -InterfaceAlias scope pinned the rule to the WSL
# adapter's interface GUID, which WSL regenerates across
# `wsl --shutdown`, host reboots, and feature toggles - stranding the
# rule on a dead GUID so the live adapter's inbound 2222 fell through
# to default-deny, and forcing a re-provision just to rebind it.
# WSL2's NAT subnet always sits inside 172.16.0.0/12, while the host's
# real LAN and the router's Internal-switch subnet do not, so scoping
# the allow to that range keeps the router's password-auth SSH off the
# physical LAN AND survives adapter GUID churn with no re-provision -
# the rule has nothing volatile to go stale against.
#
# Trade-off: scope is by source-IP range rather than ingress
# interface, so any 172.16/12 peer (in practice only WSL) is allowed,
# and a host that also joins a 172.16/12 network would widen it -
# pass -WslNatRange to narrow the range if that applies.
#
# Refreshed (delete + re-add) on every run so the rule always carries
# the current scope; this also migrates any older interface-pinned
# rule left by a previous version to the range-scoped form.
#
# No-op on hosts without a WSL adapter installed; the rest of the
# provisioner stays usable on Linux/Mac developer boxes that
# exercise these helpers via Pester.
# ---------------------------------------------------------------------------

function Set-RouterSshPortProxyFirewall {
    [CmdletBinding()]
    param(
        # Listen port the inbound rule covers. Must match the
        # Set-RouterSshPortProxy listen port - same default.
        [int]    $ListenPort  = 2222,

        # Source-IP range the allow is scoped to. Defaults to the
        # private range WSL2's NAT always allocates from; narrow it if
        # the host also lives on a 172.16/12 network.
        [string] $WslNatRange = '172.16.0.0/12'
    )

    # Gate on WSL being installed (adapter present). Get-NetAdapter
    # returns nothing on hosts without WSL; the rule is pointless there,
    # and New-NetFirewallRule does not exist on Linux/Mac at all. The
    # adapter is used only as this presence signal - the rule's scope no
    # longer depends on it, which is what makes it reboot-durable.
    $wslAdapter = Get-NetAdapter -ErrorAction SilentlyContinue |
                  Where-Object { $_.Name -like 'vEthernet (WSL*' } |
                  Select-Object -First 1

    if (-not $wslAdapter) {
        Write-Host " [firewall] no vEthernet (WSL*) adapter found; skipping firewall rule (WSL probably not installed)."
        return
    }

    $ruleName = "Vm-Provisioner: WSL -> router SSH portproxy (TCP/$ListenPort)"

    # Delete any existing same-named rule before re-adding so the rule
    # always carries the current range scope - and any older
    # interface-pinned rule from a previous version is migrated to it.
    $existing = Get-NetFirewallRule -DisplayName $ruleName -ErrorAction SilentlyContinue
    if ($existing) {
        Write-Host " [firewall] inbound rule '$ruleName' present; refreshing to scope it to $WslNatRange."
        Remove-NetFirewallRule -DisplayName $ruleName
    } else {
        Write-Host " [firewall] adding inbound TCP/$ListenPort allow from $WslNatRange (WSL NAT range)"
    }

    New-NetFirewallRule `
        -DisplayName   $ruleName `
        -Direction     Inbound `
        -LocalPort     $ListenPort `
        -Protocol      TCP `
        -Action        Allow `
        -RemoteAddress $WslNatRange | Out-Null
}