Running SSH securely and reproducibly on WSL2

ArticleCreated 2026-07-30Updated 2026-08-26reviewed

Linux OperationsAnsibleAutomationSecurity-conscious Infrastructure

A practical two-phase rollout for a key-only OpenSSH service on WSL2, including preflight checks, scoped firewall rules, an Ansible role, rollback paths and external acceptance tests.

A practical implementation of a key-only OpenSSH service on WSL2, with explicit safety gates, a scoped Hyper-V firewall rule, an Ansible-managed configuration and tested recovery paths.

Estimated reading time: 18–22 minutes, plus time to adapt and test the examples.

Installing openssh-server inside a WSL2 distribution is easy. Changing an already working remote-access path without locking yourself out is not. The SSH daemon is only the final component in a chain that starts in Windows, crosses the Hyper-V firewall and mirrored networking, and ends at the Linux service.

This article turns an implemented setup into a sanitized, reproducible example. It includes the commands and the core files needed to build the same control flow, but it deliberately contains no production addresses, user names, key material or internal host names. Every value beginning with REPLACE_ME must be replaced before a write operation is allowed.

The example assumes a recent Windows 11 release, WSL2, a systemd-enabled Debian or Ubuntu distribution, an existing working key-based SSH login and Ansible installed inside that distribution. Package installation and key distribution are separate bootstrap activities. The hardening role refuses to improvise either one.

1. Define the permitted result before changing anything

The target state is intentionally narrow:

The listener in this implementation uses 0.0.0.0 inside WSL. That makes the Linux-side behavior predictable when mirrored interfaces change, but it also means the listener itself is not the network boundary. The Hyper-V firewall restricts the source network and port, while AllowUsers applies a second source check inside OpenSSH.

Forwarding restrictions are defense in depth, not sandboxing. The OpenSSH manual explicitly notes that a user with a normal shell can run another forwarding program. If a user must not establish arbitrary network connections, enforce that separately with operating-system or network controls.

Before proceeding, prepare all of the following:

Do not run the examples over your only session.

This threat model is deliberately modest. It protects a developer or operator endpoint from accidental broad exposure, weak SSH authentication and undocumented configuration drift. It also makes a lockout less likely during maintenance. It does not protect the Windows account from a malicious local administrator, isolate an interactive Linux user after login or make the WSL virtual machine highly available.

The distinction matters because hardening lists tend to mix controls from different layers. PasswordAuthentication no changes what OpenSSH offers. A Hyper-V rule changes which packets reach WSL. File permissions influence whether OpenSSH trusts authorized_keys. None of those controls can replace the others, and a successful check at one layer says nothing about the remaining path.

The rollout therefore uses fail-closed gates. Missing packages are not installed implicitly. Unknown keys are not added or removed. Ambiguous .wslconfig input is not normalized. Existing firewall rules that cannot be attributed to this change are not deleted. A failed recovery still produces a failed Ansible result, because restoring availability is not the same as successfully applying the requested state.

There are also explicit non-goals. This example does not configure WSL autostart, rotate host keys, manage private client keys, install a host firewall inside Linux or attempt to bypass centrally managed Windows policy. Those can all be valid follow-up projects, but combining them with the first SSH hardening rollout would enlarge both the failure domain and the rollback problem.

2. Understand the complete access path

The connection crosses three configuration domains:

External client
  -> Windows / Hyper-V firewall
  -> WSL2 mirrored networking
  -> Linux IPv4 listener
  -> OpenSSH authentication and authorization

Microsoft documents .wslconfig as a global configuration file for all WSL2 distributions belonging to the Windows user. The Hyper-V firewall also targets the WSL virtual-machine creator rather than a traditional Linux interface. A change in either place can therefore affect more than the selected distribution.

Treat the Windows distribution name and the Linux release as separate facts. A friendly name from wsl --list does not prove what /etc/os-release reports inside that instance. Likewise, the same socket can appear from more than one WSL view when distributions share underlying networking. A useful preflight correlates the address, port, process and target distribution instead of merely searching for the text :22.

3. Run a read-only preflight

Start in a normal, non-elevated PowerShell session. Replace the distribution placeholder and inspect the host-side state:

$DistroName = "REPLACE_ME_WSL_DISTRIBUTION"

if ($DistroName.StartsWith("REPLACE_ME")) {
    throw "Set the registered WSL distribution name first."
}

wsl.exe --version
wsl.exe --status
wsl.exe --list --verbose
wsl.exe --list --running
wsl.exe --distribution $DistroName -- uname -r
wsl.exe --distribution $DistroName -- cat /etc/os-release

The selected distribution must be WSL2. Inside it, verify the kernel, PID 1, persistent systemd setting, package, service and listener:

set -eu

uname -r | grep -qi microsoft
test "$(ps -p 1 -o comm=)" = systemd
grep -A2 -E '^\[boot\]$' /etc/wsl.conf
dpkg-query -W -f='${Status} ${Version}\n' openssh-server
systemctl is-enabled ssh
systemctl is-active ssh
sudo ss -H -lntp 'sport = :22'

If OpenSSH or Ansible is missing, stop the rollout and perform a separate, reviewed bootstrap. On an Ubuntu or Debian test installation that can be as simple as the following, but production package policy may require pinned versions or an internal mirror:

sudo apt-get update
sudo apt-get install --yes openssh-server ansible-core

The hardening role shown later only accepts an existing package and service. It does not download software while changing remote access.

Next, inspect the existing authorization files. Replace the user placeholder, but never paste a private key into a terminal, variable file or article:

SSH_USER=REPLACE_ME_LINUX_USER

sudo stat -c '%U:%G %a %n' \
  "/home/${SSH_USER}/.ssh" \
  "/home/${SSH_USER}/.ssh/authorized_keys"

sudo ssh-keygen -l -E sha256 \
  -f "/home/${SSH_USER}/.ssh/authorized_keys"

sudo ssh-keygen -l -E sha256 \
  -f /etc/ssh/ssh_host_ed25519_key.pub

The expected modes are normally 700 for .ssh and 600 for authorized_keys, owned by the login user. Record only the approved SHA256: fingerprints. Compare the host-key fingerprint through an already trusted channel immediately before the external login test.

Finally, prove the baseline from the independent client while the old configuration is still active:

ssh -o IdentitiesOnly=yes \
  -i ~/.ssh/REPLACE_ME_KEY_FILE \
  REPLACE_ME_LINUX_USER@REPLACE_ME_WSL_HOST

Keep this session open. It is evidence that the key works and a recovery path if a later configuration is wrong.

A good preflight records facts rather than merely printing them. Save the distribution identity, package version, service state, listener ownership, approved public-key fingerprints, host-key fingerprint and relevant firewall output with the change record. Do not record the private key, a decrypted vault or a full environment dump. The aim is to make the decision reproducible without turning the evidence bundle into a credential archive.

Stop on unexplained differences. Two authorized keys when one was approved, an SSH listener owned by another process, a disabled service that happens to be running, or a second broad inbound firewall rule are not harmless details. Each one changes either the access path or the recovery assumptions. Investigate it as a separate change before continuing.

The same rule applies to key fingerprints. Comparing a set of fingerprints is stronger than checking that one known key appears somewhere in the file: the latter would silently accept an additional unreviewed key. The minimal role below requires exact set equality and leaves key provisioning outside its scope. This separation keeps a hardening run from unexpectedly changing who can log in.

4. Phase A: mirrored networking and the Hyper-V firewall

Inspect and update .wslconfig

Back up the global WSL configuration in a normal PowerShell session before editing it:

$WslConfig = Join-Path $env:USERPROFILE ".wslconfig"
$Stamp = Get-Date -Format "yyyyMMdd-HHmmss"

if (Test-Path -LiteralPath $WslConfig) {
    Copy-Item -LiteralPath $WslConfig -Destination "$WslConfig.$Stamp.bak"
    Get-Content -LiteralPath $WslConfig
}

The desired section is:

[wsl2]
networkingMode=mirrored
firewall=true

Do not blindly overwrite an existing file. Preserve unrelated settings and comments. If the file contains more than one [wsl2] section or conflicting networkingMode keys, stop and resolve the ambiguity manually. The production helper used for this rollout preserved encoding, line endings and access control entries and refused ambiguous input; a short Set-Content one-liner would not provide those guarantees.

WSL configuration changes only take effect after the WSL virtual machine has stopped completely. Close disposable sessions, keep the recovery plan ready, and then perform the transition explicitly from PowerShell:

wsl.exe --list --running
wsl.exe --shutdown

Start the target distribution again and repeat the complete preflight. The automation must not call wsl --shutdown itself because that would destroy the sessions intended to protect the rollout.

Create one scoped Hyper-V firewall rule

Use a dedicated rule name and refuse to modify unrelated rules. The following minimal helper is deliberately conservative: it creates or verifies exactly one local rule, rejects a conflicting rule with the same name and can remove only that named rule.

Save it as scripts/Manage-WslSshFirewall.ps1:

[CmdletBinding()]
param(
    [ValidateSet("Check", "Apply", "Rollback")]
    [string]$Mode = "Check",
    [Parameter(Mandatory = $true)]
    [string]$RuleName,
    [Parameter(Mandatory = $true)]
    [string]$RemoteAddress,
    [int]$LocalPort = 22
)

Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$WslVmCreatorId = "{40E0AC32-46A5-438A-A0B2-2B479E8F2E90}"

if ($RuleName.StartsWith("REPLACE_ME") -or
    $RemoteAddress.StartsWith("REPLACE_ME")) {
    throw "Replace every placeholder before continuing."
}

function Test-IsAdministrator {
    $identity = [Security.Principal.WindowsIdentity]::GetCurrent()
    $principal = [Security.Principal.WindowsPrincipal]::new($identity)
    $principal.IsInRole(
        [Security.Principal.WindowsBuiltInRole]::Administrator
    )
}

function Get-ProjectRule([string]$Store) {
    @(Get-NetFirewallHyperVRule `
        -PolicyStore $Store `
        -VMCreatorId $WslVmCreatorId `
        -Name $RuleName `
        -ErrorAction SilentlyContinue)
}

$persistent = @(Get-ProjectRule "PersistentStore")
$active = @(Get-ProjectRule "ActiveStore")

if ($Mode -eq "Check") {
    [pscustomobject]@{
        persistent_count = $persistent.Count
        active_count = $active.Count
        rule = $active
    } | ConvertTo-Json -Depth 6
    exit 0
}

if (-not (Test-IsAdministrator)) {
    throw "Apply and rollback require an elevated PowerShell session."
}

if ($Mode -eq "Rollback") {
    if ($persistent.Count -eq 1) {
        Remove-NetFirewallHyperVRule `
            -PolicyStore PersistentStore `
            -Name $RuleName `
            -Confirm:$false
    }
    exit 0
}

if ($persistent.Count -gt 1) {
    throw "More than one local rule has the requested name."
}

$created = $false
try {
    if ($persistent.Count -eq 0) {
        New-NetFirewallHyperVRule `
            -PolicyStore PersistentStore `
            -Name $RuleName `
            -DisplayName $RuleName `
            -Direction Inbound `
            -VMCreatorId $WslVmCreatorId `
            -Protocol TCP `
            -LocalAddresses Any `
            -LocalPorts $LocalPort `
            -RemoteAddresses $RemoteAddress `
            -RemotePorts Any `
            -Action Allow `
            -Enabled True `
            -Profiles Any | Out-Null
        $created = $true
    }

    $verified = @(Get-ProjectRule "ActiveStore")
    if ($verified.Count -ne 1 -or
        ([string]$verified[0].Direction) -notmatch '^(Inbound|1)$' -or
        ([string]$verified[0].Protocol) -notmatch '^(TCP|6)$' -or
        ([string]$verified[0].Action) -notmatch '^(Allow|2)$' -or
        ([string]$verified[0].Enabled) -notmatch '^(True|1)$' -or
        -not (@($verified[0].LocalPorts) -contains [string]$LocalPort) -or
        -not (@($verified[0].RemoteAddresses) -contains $RemoteAddress)) {
        throw "The scoped rule is not effective in ActiveStore."
    }

    $verified | ConvertTo-Json -Depth 6
}
catch {
    if ($created) {
        Remove-NetFirewallHyperVRule `
            -PolicyStore PersistentStore `
            -Name $RuleName `
            -Confirm:$false `
            -ErrorAction SilentlyContinue
    }
    throw
}

Run Check first in a normal PowerShell session. Run Apply only in a separately opened elevated session:

$RuleName = "REPLACE_ME_UNIQUE_RULE_NAME"
$TrustedCidr = "REPLACE_ME_TRUSTED_CIDR"

.\scripts\Manage-WslSshFirewall.ps1 `
    -Mode Check `
    -RuleName $RuleName `
    -RemoteAddress $TrustedCidr `
    -LocalPort 22

# Repeat from an elevated PowerShell session after reviewing the output.
.\scripts\Manage-WslSshFirewall.ps1 `
    -Mode Apply `
    -RuleName $RuleName `
    -RemoteAddress $TrustedCidr `
    -LocalPort 22

Do not change the WSL-wide default inbound action to Allow. A rule scoped to TCP/22 and the trusted source CIDR is easier to reason about and test. Group Policy or centrally managed rules may still override local intent; the ActiveStore check is therefore essential.

The helper's rule name is part of its ownership boundary. Choose a name unique to this service and keep it stable. On a later run, a matching local rule can be verified; a conflicting rule with the same name causes failure rather than an in-place rewrite. The production implementation can extend this model by snapshotting a previously owned rule before an update, but it should retain the same sequence: create or update the owned object, verify its effective form, and only then remove an explicitly named legacy object.

The source CIDR must describe the address Windows and OpenSSH actually observe, not the address an operator expects conceptually. VPN clients, NAT and host forwarding can alter that value. Capture the client address from a real connection and run both an inside-CIDR and outside-CIDR test. Avoid widening the range just to make a failing test pass; first determine which layer changed the address.

Also inspect the broader rule set. The scoped project rule is not meaningful if another local or centrally delivered rule already permits TCP/22 from any source. The minimal helper prints only its owned rule, so the operational preflight should additionally review all effective WSL rules and the WSL VM setting. Treat an unexplained broad allow as a blocker even when the new rule itself is correct.

5. Stop and test the first trust boundary

Do not continue directly to SSH hardening. From the independent client, verify that TCP/22 and the baseline login work through the intended network path:

nc -vz REPLACE_ME_WSL_HOST 22

ssh -o IdentitiesOnly=yes \
  -i ~/.ssh/REPLACE_ME_KEY_FILE \
  REPLACE_ME_LINUX_USER@REPLACE_ME_WSL_HOST

Repeat the port test from a client outside the trusted CIDR; it must fail. A test from the Windows host or the target WSL distribution cannot prove that the external firewall boundary works.

Only after both results are recorded should the SSH phase be unlocked.

6. Build the minimal Ansible role

The public example keeps the Windows firewall helper separate and uses Ansible locally inside WSL for Linux-side inspection and hardening:

wsl-ssh-hardening/
├── group_vars/all.yml
├── inventory.yml
├── playbook.yml
├── run.sh
├── scripts/Manage-WslSshFirewall.ps1
└── roles/wsl_ssh_server/
    ├── files/validate-sshd-config.sh
    ├── handlers/main.yml
    ├── tasks/main.yml
    └── templates/00-wsl-ssh.conf.j2

This split is intentional. Windows elevation and Linux privilege escalation are different trust boundaries. The firewall phase uses an elevated Windows token and ends with an external checkpoint. The SSH phase uses Linux sudo only after that checkpoint has succeeded. A single command can still dispatch the phases, but no wrapper flag should silently turn one confirmation into approval for both.

The example is small enough to audit, yet it retains the properties that matter in production: placeholders default to failure, checks run before writes, the candidate is evaluated with existing configuration, the managed file has a single owner, activation uses reload, and rescue restores the prior state. Additional reporting and platform-version gates can be layered around this core without changing its control flow.

The inventory deliberately uses the local connection:

all:
  hosts:
    localhost:
      ansible_connection: local

Store only public configuration data in group_vars/all.yml. Fingerprints identify approved public keys; they are not secret. Private keys never belong here.

wsl_ssh_apply_confirmed: false
wsl_ssh_firewall_post_test_confirmed: false

wsl_ssh_user: "REPLACE_ME_LINUX_USER"
wsl_ssh_port: 22
wsl_ssh_trusted_cidr: "REPLACE_ME_TRUSTED_CIDR"
wsl_ssh_expected_key_fingerprints:
  - "REPLACE_ME_SHA256_PUBLIC_KEY_FINGERPRINT"
wsl_ssh_permit_open:
  - "REPLACE_ME_FORWARD_HOST:REPLACE_ME_FORWARD_PORT"

wsl_ssh_dropin_name: "00-wsl-ssh.conf"
wsl_ssh_dropin_path: "/etc/ssh/sshd_config.d/00-wsl-ssh.conf"
wsl_ssh_service_name: "ssh"

The playbook applies exactly one role to the local distribution:

---
- name: Validate and harden the local WSL SSH service
  hosts: localhost
  gather_facts: true
  become: true
  roles:
    - wsl_ssh_server

The managed OpenSSH drop-in

Create roles/wsl_ssh_server/templates/00-wsl-ssh.conf.j2:

# Managed by Ansible. Local edits will be replaced.
Port {{ wsl_ssh_port }}
AddressFamily inet
ListenAddress 0.0.0.0:{{ wsl_ssh_port }}

PubkeyAuthentication yes
AuthenticationMethods publickey
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitEmptyPasswords no
PermitRootLogin no
AllowUsers {{ wsl_ssh_user }}@{{ wsl_ssh_trusted_cidr }}

AllowAgentForwarding no
X11Forwarding no
PermitTunnel no
GatewayPorts no
AllowStreamLocalForwarding no
AllowTcpForwarding local
PermitListen none
PermitOpen {{ wsl_ssh_permit_open | join(' ') }}

MaxAuthTries 3
LoginGraceTime 30
ClientAliveInterval 300
ClientAliveCountMax 2

OpenSSH normally uses the first value it obtains for a keyword. Naming the managed file 00-wsl-ssh.conf gives it precedence over later distribution drop-ins. That convention is useful only if the main configuration contains exactly one standard Include /etc/ssh/sshd_config.d/*.conf before conflicting global directives. The validation helper refuses other layouts instead of guessing.

Validate the complete candidate

Create roles/wsl_ssh_server/files/validate-sshd-config.sh:

#!/usr/bin/env bash
set -euo pipefail

mode="${1:-}"
candidate="${2:-}"
managed_name="${3:-00-wsl-ssh.conf}"
expected_user="${4:-}"
expected_cidr="${5:-}"
expected_port="${6:-22}"
expected_permit_open_csv="${7:-}"
expected_permit_open="${expected_permit_open_csv//,/ }"

main=/etc/ssh/sshd_config
dropins=/etc/ssh/sshd_config.d
effective="$main"
temporary_root=""

cleanup() {
  if [[ -n "$temporary_root" && -d "$temporary_root" ]]; then
    rm -rf -- "$temporary_root"
  fi
}
trap cleanup EXIT

fail() {
  printf '%s\n' "$*" >&2
  exit 1
}

if [[ "$mode" != candidate && "$mode" != active ]]; then
  fail "Use: $0 candidate <file> <name> <user> <cidr> <port> <permit-open-csv> | active"
fi

if [[ "$mode" == candidate ]]; then
  [[ -r "$candidate" ]] || fail "Candidate is not readable."

  include_count="$(grep -Ec \
    '^[[:space:]]*Include[[:space:]]+/etc/ssh/sshd_config\.d/\*\.conf[[:space:]]*$' \
    "$main" || true)"
  [[ "$include_count" == 1 ]] || fail "Expected one standard drop-in Include."

  temporary_root="$(mktemp -d /run/wsl-ssh-validate.XXXXXX)"
  chmod 0700 "$temporary_root"
  install -d -m 0700 "$temporary_root/sshd_config.d"

  while IFS= read -r -d '' existing; do
    grep -Eq '^[[:space:]]*Include[[:space:]]+' "$existing" && \
      fail "Nested Include in $existing"
    install -m 0600 "$existing" \
      "$temporary_root/sshd_config.d/$(basename "$existing")"
  done < <(find "$dropins" -maxdepth 1 -type f -name '*.conf' \
    ! -name "$managed_name" -print0 | sort -z)

  install -m 0600 "$candidate" \
    "$temporary_root/sshd_config.d/$managed_name"

  while IFS= read -r line || [[ -n "$line" ]]; do
    if [[ "$line" =~ ^[[:space:]]*Include[[:space:]]+/etc/ssh/sshd_config\.d/\*\.conf[[:space:]]*$ ]]; then
      printf 'Include %s/sshd_config.d/*.conf\n' "$temporary_root"
    else
      printf '%s\n' "$line"
    fi
  done <"$main" >"$temporary_root/sshd_config"
  chmod 0600 "$temporary_root/sshd_config"
  effective="$temporary_root/sshd_config"
fi

/usr/sbin/sshd -t -f "$effective"

if [[ "$mode" == candidate ]]; then
  for context in \
    "user=$expected_user,addr=192.0.2.10,host=allowed.example.invalid" \
    "user=$expected_user,addr=198.51.100.10,host=outside.example.invalid" \
    "user=root,addr=192.0.2.10,host=allowed.example.invalid"
  do
    output="$(/usr/sbin/sshd -T -f "$effective" -C "$context")"
    grep -Fxq "port $expected_port" <<<"$output"
    grep -Fxq "addressfamily inet" <<<"$output"
    grep -Fxq "listenaddress 0.0.0.0:$expected_port" <<<"$output"
    grep -Fxq "authenticationmethods publickey" <<<"$output"
    grep -Fxq "passwordauthentication no" <<<"$output"
    grep -Fxq "kbdinteractiveauthentication no" <<<"$output"
    grep -Fxq "permitrootlogin no" <<<"$output"
    grep -Fxq "allowusers $expected_user@$expected_cidr" <<<"$output"
    grep -Fxq "allowtcpforwarding local" <<<"$output"
    grep -Fxq "permitlisten none" <<<"$output"
    grep -Fxq "permitopen $expected_permit_open" <<<"$output"
  done
fi

The documentation-only addresses above come from reserved example ranges. sshd -T -C proves which directives are effective for the supplied connection context, but it does not perform a login and does not by itself prove that AllowUsers rejects a connection. That evidence must come from the external negative tests.

Candidate validation must use the same include order as the installed daemon. Testing the drop-in as if it were a standalone sshd_config would be misleading: it omits distribution defaults, existing fragments and the host-key configuration needed by sshd -t. The helper creates a root-only temporary tree, copies existing fragments except the managed one, inserts the candidate under its final name and rewrites exactly the standard include path in a temporary copy of the main file.

The helper refuses nested includes and unexpected main-file layouts because following an arbitrary include graph safely requires more than a compact example. Refusal is the desirable result: it identifies a platform assumption that must be reviewed. If a distribution uses a different include structure, adapt the helper deliberately and add a fixture representing that structure rather than weakening the check.

The three -C contexts are useful even without claiming they execute authorization. They reveal conditional Match behavior that can change authentication or forwarding options for a particular user or source. The external tests remain authoritative for allow and deny outcomes, while the effective-configuration output explains why those outcomes should occur.

Preflight, candidate and rollback tasks

Create roles/wsl_ssh_server/tasks/main.yml:

---
- name: Reject placeholders and unsafe apply attempts
  ansible.builtin.assert:
    that:
      - not wsl_ssh_user.startswith('REPLACE_ME')
      - not wsl_ssh_trusted_cidr.startswith('REPLACE_ME')
      - wsl_ssh_expected_key_fingerprints | length > 0
      - wsl_ssh_expected_key_fingerprints | reject('match', '^REPLACE_ME') | list | length == wsl_ssh_expected_key_fingerprints | length
      - wsl_ssh_permit_open | length > 0
      - wsl_ssh_permit_open | reject('match', '^REPLACE_ME') | list | length == wsl_ssh_permit_open | length
      - not wsl_ssh_apply_confirmed | bool or wsl_ssh_firewall_post_test_confirmed | bool
    fail_msg: Replace all placeholders and confirm the external firewall test.

- name: Require WSL2 and systemd
  ansible.builtin.shell: |
    set -eu
    uname -r | grep -qi microsoft
    test "$(ps -p 1 -o comm=)" = systemd
  args:
    executable: /bin/bash
  changed_when: false

- name: Collect package facts
  ansible.builtin.package_facts:
    manager: auto

- name: Require the existing OpenSSH package
  ansible.builtin.assert:
    that:
      - "'openssh-server' in ansible_facts.packages"

- name: Inspect authorization paths
  ansible.builtin.stat:
    path: "{{ item }}"
  loop:
    - "/home/{{ wsl_ssh_user }}/.ssh"
    - "/home/{{ wsl_ssh_user }}/.ssh/authorized_keys"
  register: wsl_ssh_auth_paths

- name: Require safe authorization ownership and modes
  ansible.builtin.assert:
    that:
      - wsl_ssh_auth_paths.results[0].stat.pw_name == wsl_ssh_user
      - wsl_ssh_auth_paths.results[0].stat.mode == '0700'
      - wsl_ssh_auth_paths.results[1].stat.pw_name == wsl_ssh_user
      - wsl_ssh_auth_paths.results[1].stat.mode == '0600'

- name: Read authorized-key fingerprints
  ansible.builtin.command:
    argv:
      - ssh-keygen
      - -l
      - -E
      - sha256
      - -f
      - "/home/{{ wsl_ssh_user }}/.ssh/authorized_keys"
  register: wsl_ssh_key_fingerprints
  changed_when: false

- name: Derive the fingerprint set
  ansible.builtin.set_fact:
    wsl_ssh_actual_key_fingerprints: >-
      {{ wsl_ssh_key_fingerprints.stdout_lines
         | map('regex_replace', '^\\d+ (SHA256:[^ ]+).+$', '\\1')
         | unique | sort | list }}

- name: Require the exact approved fingerprint set
  ansible.builtin.assert:
    that:
      - wsl_ssh_actual_key_fingerprints == (wsl_ssh_expected_key_fingerprints | unique | sort | list)

- name: Confirm the existing service and listener
  ansible.builtin.shell: |
    set -eu
    systemctl is-enabled {{ wsl_ssh_service_name | quote }}
    systemctl is-active {{ wsl_ssh_service_name | quote }}
    ss -H -lntp 'sport = :{{ wsl_ssh_port }}' | grep -q 'sshd'
  args:
    executable: /bin/bash
  changed_when: false

- name: Validate a complete temporary candidate
  block:
    - name: Create a root-only candidate directory
      ansible.builtin.command:
        argv: [mktemp, -d, /run/wsl-ssh-candidate.XXXXXX]
      register: wsl_ssh_candidate_dir
      changed_when: false
      check_mode: false

    - name: Render the candidate outside the active configuration
      ansible.builtin.template:
        src: 00-wsl-ssh.conf.j2
        dest: "{{ wsl_ssh_candidate_dir.stdout }}/{{ wsl_ssh_dropin_name }}"
        owner: root
        group: root
        mode: '0600'
      check_mode: false

    - name: Install the validation helper temporarily
      ansible.builtin.copy:
        src: validate-sshd-config.sh
        dest: "{{ wsl_ssh_candidate_dir.stdout }}/validate-sshd-config.sh"
        owner: root
        group: root
        mode: '0700'
      check_mode: false

    - name: Validate syntax and effective directives
      ansible.builtin.command:
        argv:
          - "{{ wsl_ssh_candidate_dir.stdout }}/validate-sshd-config.sh"
          - candidate
          - "{{ wsl_ssh_candidate_dir.stdout }}/{{ wsl_ssh_dropin_name }}"
          - "{{ wsl_ssh_dropin_name }}"
          - "{{ wsl_ssh_user }}"
          - "{{ wsl_ssh_trusted_cidr }}"
          - "{{ wsl_ssh_port | string }}"
          - "{{ wsl_ssh_permit_open | join(',') }}"
      changed_when: false
      check_mode: false
  always:
    - name: Remove temporary candidate material
      ansible.builtin.file:
        path: "{{ wsl_ssh_candidate_dir.stdout }}"
        state: absent
      check_mode: false
      when:
        - wsl_ssh_candidate_dir is defined
        - wsl_ssh_candidate_dir.stdout is defined

- name: Predict managed drop-in drift
  ansible.builtin.template:
    src: 00-wsl-ssh.conf.j2
    dest: "{{ wsl_ssh_dropin_path }}"
    owner: root
    group: root
    mode: '0644'
  check_mode: true
  diff: true
  when: not wsl_ssh_apply_confirmed | bool

- name: Install the validated drop-in with recovery
  when: wsl_ssh_apply_confirmed | bool
  block:
    - name: Inspect the previous managed file
      ansible.builtin.stat:
        path: "{{ wsl_ssh_dropin_path }}"
      register: wsl_ssh_previous_dropin

    - name: Install the managed drop-in atomically
      ansible.builtin.template:
        src: 00-wsl-ssh.conf.j2
        dest: "{{ wsl_ssh_dropin_path }}"
        owner: root
        group: root
        mode: '0644'
        backup: true
      register: wsl_ssh_install
      notify: Reload OpenSSH

    - name: Validate the installed configuration
      ansible.builtin.command:
        argv: [/usr/sbin/sshd, -t]
      changed_when: false

    - name: Activate the validated configuration
      ansible.builtin.meta: flush_handlers
  rescue:
    - name: Restore a backed-up managed file
      ansible.builtin.copy:
        src: "{{ wsl_ssh_install.backup_file }}"
        dest: "{{ wsl_ssh_dropin_path }}"
        remote_src: true
        owner: root
        group: root
        mode: '0644'
      when:
        - wsl_ssh_previous_dropin.stat.exists | default(false)
        - wsl_ssh_install.backup_file is defined

    - name: Remove a newly introduced managed file
      ansible.builtin.file:
        path: "{{ wsl_ssh_dropin_path }}"
        state: absent
      when: not (wsl_ssh_previous_dropin.stat.exists | default(false))

    - name: Validate the recovered configuration
      ansible.builtin.command:
        argv: [/usr/sbin/sshd, -t]
      changed_when: false

    - name: Reload the recovered configuration
      ansible.builtin.systemd_service:
        name: "{{ wsl_ssh_service_name }}"
        state: reloaded

    - name: Preserve the original failure result
      ansible.builtin.fail:
        msg: SSH hardening failed; the previous configuration was restored.

The temporary files under /run are real files, even during --check, but they never enter /etc/ssh and are removed in the always block. This is intentional: Ansible check mode is a simulation, and command tasks without explicit handling may otherwise be skipped. The dedicated preflight performs the read-only checks needed for this change rather than treating a template diff as proof.

Idempotence has two meanings here. The managed drop-in should converge so a second successful apply makes no file change. More importantly, the safety checks must produce the same decision from the same facts. If a fingerprint, source range, listener or include layout changes, a later run should fail visibly rather than converge around the new state without review.

The rescue block is deliberately narrower than a general system rollback. It owns one file and the service reload caused by that file. It does not restore distribution packages, rewrite the main SSH configuration or alter keys. Narrow ownership makes recovery predictable and prevents a failed SSH change from undoing unrelated administration.

Create roles/wsl_ssh_server/handlers/main.yml:

---
- name: Reload OpenSSH
  ansible.builtin.systemd_service:
    name: "{{ wsl_ssh_service_name }}"
    state: reloaded

Expose the three controlled modes

Create run.sh and make it executable:

#!/usr/bin/env bash
set -euo pipefail

root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
mode="${1:-}"
shift || true

case "$mode" in
  check)
    ansible-playbook -i "$root/inventory.yml" "$root/playbook.yml" \
      --ask-become-pass --check --diff "$@"
    ;;
  firewall)
    [[ "${WSL_SSH_FIREWALL_CONFIRMED:-}" == YES ]] || {
      printf '%s\n' 'Set WSL_SSH_FIREWALL_CONFIRMED=YES after review.' >&2
      exit 2
    }
    script="$(wslpath -w "$root/scripts/Manage-WslSshFirewall.ps1")"
    powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$script" \
      -Mode Apply \
      -RuleName "${WSL_SSH_RULE_NAME:?Set WSL_SSH_RULE_NAME}" \
      -RemoteAddress "${WSL_SSH_TRUSTED_CIDR:?Set WSL_SSH_TRUSTED_CIDR}" \
      -LocalPort "${WSL_SSH_PORT:-22}"
    ;;
  sshd)
    [[ "${WSL_SSH_APPLY_CONFIRMED:-}" == YES ]] || {
      printf '%s\n' 'Set WSL_SSH_APPLY_CONFIRMED=YES after review.' >&2
      exit 2
    }
    [[ "${WSL_SSH_POST_FIREWALL_TEST_CONFIRMED:-}" == YES ]] || {
      printf '%s\n' 'Confirm the independent post-firewall test first.' >&2
      exit 2
    }
    ansible-playbook -i "$root/inventory.yml" "$root/playbook.yml" \
      --ask-become-pass --diff \
      -e wsl_ssh_apply_confirmed=true \
      -e wsl_ssh_firewall_post_test_confirmed=true "$@"
    ;;
  *)
    printf 'Usage: %s check|firewall|sshd\n' "$0" >&2
    exit 2
    ;;
esac

The firewall mode must be launched from a WSL shell opened by an elevated Windows Terminal. Linux sudo does not grant a Windows process an elevated token. The helper checks this and aborts if the Windows side is not administrative.

Run the preflight first:

chmod 0700 run.sh roles/wsl_ssh_server/files/validate-sshd-config.sh
./run.sh check

After the firewall phase and independent checkpoint, apply the SSH phase explicitly:

WSL_SSH_APPLY_CONFIRMED=YES \
WSL_SSH_POST_FIREWALL_TEST_CONFIRMED=YES \
./run.sh sshd

Never close the baseline sessions just because Ansible reports success.

7. Prove both permitted and rejected behavior

First inspect the listener and service inside WSL:

sudo /usr/sbin/sshd -t
sudo ss -H -lntp 'sport = :22'
systemctl is-enabled ssh
systemctl is-active ssh

Then run positive tests from the trusted external client.

Interactive shell:

ssh -o IdentitiesOnly=yes \
  -i ~/.ssh/REPLACE_ME_KEY_FILE \
  REPLACE_ME_LINUX_USER@REPLACE_ME_WSL_HOST

SFTP and rsync:

printf 'ls\nquit\n' | sftp \
  -i ~/.ssh/REPLACE_ME_KEY_FILE \
  REPLACE_ME_LINUX_USER@REPLACE_ME_WSL_HOST

rsync -an -e 'ssh -o IdentitiesOnly=yes -i ~/.ssh/REPLACE_ME_KEY_FILE' \
  ./REPLACE_ME_TEST_DIRECTORY/ \
  REPLACE_ME_LINUX_USER@REPLACE_ME_WSL_HOST:/tmp/REPLACE_ME_DESTINATION/

Allowed local forwarding:

ssh -N \
  -L 127.0.0.1:REPLACE_ME_LOCAL_PORT:REPLACE_ME_FORWARD_HOST:REPLACE_ME_FORWARD_PORT \
  -i ~/.ssh/REPLACE_ME_KEY_FILE \
  REPLACE_ME_LINUX_USER@REPLACE_ME_WSL_HOST

A minimal VS Code client entry looks like this:

Host wsl-operations
    HostName REPLACE_ME_WSL_HOST
    User REPLACE_ME_LINUX_USER
    IdentityFile ~/.ssh/REPLACE_ME_KEY_FILE
    IdentitiesOnly yes

Now test the failures. Each command is expected to return a non-zero status or an explicit administrative prohibition.

Password-only authentication:

ssh -o PubkeyAuthentication=no \
  -o PreferredAuthentications=password \
  REPLACE_ME_LINUX_USER@REPLACE_ME_WSL_HOST

Keyboard-interactive and root login:

ssh -o PubkeyAuthentication=no \
  -o PreferredAuthentications=keyboard-interactive \
  REPLACE_ME_LINUX_USER@REPLACE_ME_WSL_HOST

ssh -i ~/.ssh/REPLACE_ME_KEY_FILE \
  root@REPLACE_ME_WSL_HOST

Unknown key, remote forwarding and an unapproved local destination:

ssh -o IdentitiesOnly=yes \
  -i ~/.ssh/REPLACE_ME_UNAPPROVED_KEY \
  REPLACE_ME_LINUX_USER@REPLACE_ME_WSL_HOST

ssh -N -R REPLACE_ME_REMOTE_PORT:127.0.0.1:22 \
  -i ~/.ssh/REPLACE_ME_KEY_FILE \
  REPLACE_ME_LINUX_USER@REPLACE_ME_WSL_HOST

ssh -N -L REPLACE_ME_LOCAL_PORT:example.invalid:443 \
  -i ~/.ssh/REPLACE_ME_KEY_FILE \
  REPLACE_ME_LINUX_USER@REPLACE_ME_WSL_HOST

An IPv6 attempt must fail because AddressFamily inet suppresses the IPv6 listener:

ssh -6 REPLACE_ME_LINUX_USER@REPLACE_ME_WSL_HOST

Finally, repeat the login from outside the trusted CIDR. This is the only reliable test of the source-address boundary because VPNs, NAT and host forwarding can change the address OpenSSH actually sees.

Record command, client position, expected result, actual result and timestamp for every acceptance test. A bare statement such as “SSH works” cannot distinguish a successful public-key login from an unnoticed password fallback. Use -o IdentitiesOnly=yes for positive key tests and explicitly disable public-key authentication for password and keyboard-interactive tests so that each result exercises the intended mechanism.

Interpret forwarding failures carefully. An SSH session may authenticate successfully and then report administratively prohibited when the requested channel is opened; that is a successful negative test. Conversely, a connection failure before authentication says nothing about the forwarding policy. The test matrix should identify the layer at which each failure is expected.

VS Code Remote-SSH deserves a real test because it opens more than a simple interactive command. Confirm that the extension connects with the intended identity and that its server-side bootstrap works, then inspect the SSH logs for unexpected authentication methods or forwarding requests. Do not weaken the policy merely because an extension defaults to a feature the workflow does not require.

8. Restart and drift tests are separate acceptance criteria

A successful reload proves only the running state. Stop the distribution from PowerShell after all immediate tests have passed, start it again, and repeat the external matrix:

$DistroName = "REPLACE_ME_WSL_DISTRIBUTION"
wsl.exe --terminate $DistroName
wsl.exe --distribution $DistroName -- systemctl is-active ssh

Confirm the listener again inside WSL and the login again from the independent client. Then rerun the complete check:

./run.sh check

The final check must report no managed SSH drift and no unexpected firewall change. A self-test from inside WSL remains useful for diagnosis, but it is not evidence for the Windows and network portions of the path.

9. Use explicit rollback paths

If the SSH phase fails, the Ansible rescue block restores the previous drop-in or removes a newly introduced file, validates the recovered configuration and reloads it. The play still fails so that recovery cannot hide the original error.

If the firewall rule must be removed, use the exact project-owned name from an elevated PowerShell session:

.\scripts\Manage-WslSshFirewall.ps1 `
    -Mode Rollback `
    -RuleName "REPLACE_ME_UNIQUE_RULE_NAME" `
    -RemoteAddress "REPLACE_ME_TRUSTED_CIDR" `
    -LocalPort 22

If mirrored networking caused the problem, restore the timestamped .wslconfig backup and perform a deliberate WSL shutdown. Do not remove or rewrite unrelated firewall rules, and do not use a blanket inbound allow as an emergency workaround.

10. Operate the service after the rollout

The final configuration is a maintained state, not a one-time achievement. Rerun the read-only check after Windows, WSL, OpenSSH, VPN or network changes. Those updates can alter interface behavior, supported directives, source addresses or firewall policy without changing the Ansible repository. A scheduled report can be useful, but keep automatic remediation disabled for a remote-access path unless the same lockout protections and external tests are available.

Review the approved public-key fingerprint set when operators or devices change. Add or remove keys through a separate key-management change, verify the new exact set, and only then update the role variables. Review PermitOpen in the same way: each destination represents an intentionally supported workflow and should have an owner and reason.

Logs provide evidence that configuration alone cannot. On systemd-based distributions, inspect recent service messages with journalctl -u ssh; correlate denied users, rejected methods and forwarding failures with the acceptance test timestamps. Avoid publishing raw logs because they can contain real user names, addresses and key fingerprints.

Maintain recovery instructions alongside the automation. They should state where the .wslconfig backup is stored, which firewall rule belongs to the service, how to restore the SSH drop-in and which independent client can verify recovery. A rollback that depends on remembering an undocumented command during an outage is not a reliable rollback.

Finally, periodically stop and start the distribution as a distinct test. A service that survived a reload may still be disabled at boot, and a networking mode that worked before an update may not return in the same effective state. Startup validation belongs in the acceptance criteria rather than in an informal assumption.

11. Boundaries of the result

This setup does not turn WSL2 into a conventional always-on server. An incoming SSH connection did not automatically start a fully stopped distribution in the tested setup. WSL startup policy and general Windows endpoint hardening remain separate concerns.

The Hyper-V firewall boundary is WSL-wide rather than a perfect per-distribution boundary. Other distributions and listeners must therefore be considered during inventory. Mirrored networking is also an operational dependency whose behavior can be affected by Windows, WSL, VPN and network changes.

Most importantly, configuration validation and end-to-end validation answer different questions. sshd -t proves syntax. sshd -T -C exposes effective directives. A successful key login proves one allowed path. Only explicit negative tests demonstrate that passwords, root, unwanted forwarding and untrusted sources are rejected.

Conclusion

The safest part of this rollout is not any single sshd_config directive. It is the sequence: inventory the current state, validate a realistic candidate, change one trust boundary, test it externally, change the next boundary, and retain a tested recovery path at every step.

Reproducibility means more than putting a template in version control. It means documenting the expected inputs, refusing unknown states, proving the effective configuration and making failure visible even when rollback succeeds. That is what turns a quick SSH setup into an operable remote-access path.

For a smaller example of the same adopt-before-redesign principle, see Modeling an existing nginx site safely with Ansible.

Further reading