Running SSH securely and reproducibly on WSL2
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:
- OpenSSH listens on TCP/22 over IPv4 inside WSL.
- Only one named Linux account may log in from one trusted source CIDR.
- Public-key authentication is mandatory; password and keyboard-interactive authentication are disabled.
- Root login, agent forwarding, X11 forwarding, tunnel devices, remote TCP forwarding and Unix-socket forwarding are disabled.
- Local TCP forwarding is allowed only to explicitly listed destinations.
- Shell access, SFTP,
rsyncand Visual Studio Code Remote-SSH continue to work.
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:
- A verified baseline login using the intended key.
- One existing external SSH session that remains open during the rollout.
- A local WSL shell that remains open.
- A second client on the trusted network for independent tests.
- A separate Windows administrator path for the Hyper-V firewall change.
- A recorded SSH host-key fingerprint obtained through a trusted channel.
- A backup of
%UserProfile%\.wslconfigif that file already exists.
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
- Microsoft Learn: Accessing network applications with WSL
- Microsoft Learn: Advanced settings configuration in WSL
- Microsoft Learn: Use systemd to manage Linux services with WSL
- Microsoft Learn: Configure Hyper-V firewall
- Microsoft Learn: New-NetFirewallHyperVRule
- OpenBSD manual pages: sshd_config(5)
- OpenBSD manual pages: sshd(8)
- Ansible documentation: Check mode and diff mode
- Ansible documentation: local connection plugin
- Visual Studio Code: Remote Development using SSH