hardware/ubuntu-wsl/services/ssh.md

sshd (WSL) โ€” direct SSH access, port 2204

Direct SSH access into the WSL Ubuntu distro, separate from RDP/console access to the Windows host itself.

Set up: 2026-09-20 ยท Host: Nuk ยท Distro: Ubuntu ยท ssh -p 2204 root@192.168.1.153

1. WSL side

  • openssh-server installed. Ubuntu 26.04 ships it socket-activated on port 22 only (ssh.socket, hardcoded ListenStream=0.0.0.0:22), so that socket unit was disabled and ssh.service runs directly instead โ€” the only way to bind a non-22 port.
  • /etc/ssh/sshd_config:
    Port 2204
    PermitRootLogin prohibit-password
    PasswordAuthentication no
    PubkeyAuthentication yes
    
  • User: root โ€” this distro has no separate non-root account.
  • ~/.ssh/authorized_keys (i.e. /root/.ssh/authorized_keys) holds the keys allowed in. Each client machine's key has to be added explicitly; a key working passwordlessly into Windows does not imply it's authorized into WSL โ€” separate authorized_keys, separate trust store. Currently:
    • Windows box's own RSA key (C:\Users\domin\.ssh\id_rsa.pub, dominick@dph.am)
    • MacBook Air ed25519 key (dominick@MacBook-Air) โ€” added after first connection attempt from the Mac failed with Permission denied (publickey) because its key wasn't in authorized_keys yet.
    • Mac Studio ed25519 key (AAAA...VCIC, no comment set on the key itself) โ€” added 2026-09-20.
  • Autostart: systemctl enable --now ssh.service โ€” starts with systemd whenever the distro boots. Same caveat as everything else in this folder: the distro itself has to be booted by something on the Windows side first (see ../overview.md); systemd/sshd don't start until then.
systemctl status ssh.service
journalctl -u ssh -f
sshd -t                       # validate config after editing
systemctl restart ssh.service # after editing sshd_config

2. Windows side โ€” networking

This section is networking-mode-dependent โ€” check C:\Users\domin\.wslconfig for networkingMode before touching anything here. Getting this wrong (leaving a NAT-mode portproxy rule around after switching to mirrored) causes a very confusing symptom: sshd fails to bind its own port with Address already in use and nothing on the WSL side (ss, /proc/net/tcp, lsns) shows any process holding it โ€” because the real holder is a Windows-side listener, invisible from inside the distro. Hit this once, 2026-09-21; took a long diagnostic detour before realizing the mode had changed. Symptom checklist if this recurs: systemctl status ssh.service shows failed/Address already in use, but ss -tlnp | grep 2204 inside WSL is empty โ€” that combination is this bug.

Mirrored mode (current, as of 2026-09-21)

.wslconfig has networkingMode=mirrored (+ hostAddressLoopback=true). WSL shares the host's real network interfaces directly โ€” ip addr inside WSL shows eth1 carrying the actual LAN IP 192.168.1.153, same as the Windows host. No portproxy is needed or wanted: sshd binding 0.0.0.0:2204 inside WSL is already reachable at 192.168.1.153:2204 from the LAN, because WSL is on that interface now. Only the firewall rule is needed:

New-NetFirewallRule -DisplayName "WSL SSH 2204" `
  -Direction Inbound -Action Allow -Protocol TCP -LocalPort 2204

If a NAT-era portproxy rule for this port still exists, delete it โ€” in mirrored mode it keeps a listening socket open on the Windows side that collides with WSL's own bind attempt on the same (now-shared) interface stack, since the two are no longer isolated by NAT:

netsh interface portproxy delete v4tov4 listenport=2204 listenaddress=0.0.0.0

NAT mode (original setup, no longer active)

Same pattern as pi-web.md, pointed at loopback, not the WSL IP (which changes every reboot in NAT mode โ€” loopback is stable because WSL's own localhost-forwarding relays it regardless of the current WSL IP):

New-NetFirewallRule -DisplayName "WSL SSH 2204" `
  -Direction Inbound -Action Allow -Protocol TCP -LocalPort 2204

netsh interface portproxy add v4tov4 `
  listenaddress=0.0.0.0 listenport=2204 connectaddress=127.0.0.1 connectport=2204

An even earlier version pointed the portproxy at the dynamic WSL IP directly, refreshed by a Scheduled Task on every boot โ€” abandoned in favor of loopback (simpler, no task needed) the same day it was set up.

Check/manage (either mode)

netsh interface portproxy show v4tov4
Get-NetFirewallRule -DisplayName "WSL SSH 2204"
# inside WSL
ip addr show          # confirms current networking mode by interface shape
ss -tlnp | grep 2204

3. Reachability

From Address
WSL itself ssh -p 2204 root@127.0.0.1 (or localhost)
Windows ssh -p 2204 root@127.0.0.1 (via WSL localhost forwarding)
LAN ssh -p 2204 root@192.168.1.153

No public/WAN exposure was set up for this (unlike pi-wsl.dph.am's nginx ingress for pi-web) โ€” LAN only, via whatever router-level access Dom already has to 192.168.1.153.

4. Troubleshooting

Symptom Cause / fix
Permission denied (publickey) Client's public key isn't in /root/.ssh/authorized_keys inside WSL. Add it โ€” a key that's passwordless into Windows is not automatically trusted into WSL.
.zshrc:bindkey:NN: cannot bind to an empty key sequence on login, and/or Backspace inserts a blank/space instead of deleting Client's $TERM doesn't have a matching entry in this box's terminfo database, so zsh can't look up key capabilities (khome, kend, kich1, kcbt, cub1, etc.) โ€” they resolve to empty strings, breaking both bindkey calls in ~/.zshrc and ZLE's screen redraw (which needs cub1/cursor-left to properly erase a char on backspace). Hit this 2026-09-21 from a Mac running Ghostty, which sends TERM=xterm-ghostty by default โ€” not present in a stock Ubuntu terminfo db even with ncurses-term installed. Fix from the Mac (in Ghostty, not inside the WSL session): infocmp -x xterm-ghostty | ssh -p 2204 root@192.168.1.153 -- tic -x - (a tic warning about "older tic versions may treat the description field as an alias" is harmless โ€” verify with infocmp -1 xterm-ghostty on the WSL side afterward). Open a new SSH session after running it; existing ones won't pick up the change. Alternative if you don't want to touch the remote terminfo db: set term = xterm-256color in Ghostty's config instead. ~/.zshrc was also hardened separately to guard those four bindkey calls with [[ -n "$key_info[X]" ]] && so an unrecognized $TERM degrades silently instead of spraying errors at login.
Connects fine locally, not from LAN Check netsh interface portproxy show v4tov4 for the 2204 row and Get-NetFirewallRule -DisplayName "WSL SSH 2204"; re-create if missing (commands above, need elevated PowerShell).
sshd -t fails after an edit Fix /etc/ssh/sshd_config before restarting โ€” a bad config leaves the old process running until the next restart attempt fails too.
Works right after setup, dead after a Windows reboot Distro didn't boot up before you tried, or (if this ever regresses to IP-based portproxy instead of loopback) the WSL IP changed. Loopback portproxy avoids the second case entirely.

5. Security notes

  • Root login, key-only โ€” no password auth. Reasonable for a LAN-only personal dev box; would need hardening (non-root user, fail2ban equivalent, etc.) before any WAN exposure.
  • Every additional client key added to authorized_keys is a full root login into this box โ€” treat it like handing out a root password.