Skip to content

ssh

Module: home/shell/ssh.nix

The client config. Five hosts, and almost every line in there is a trap somebody else's server handed us.

The private key is state and a secret, so it comes from the backup, not from Nix (rule 6).

faiResilience is sized to VS Code's budget, not to comfort

The FAI tunnel drops routes transiently with ppp0 still alive, so a long session needs to tolerate the hole instead of dying. But EVERYTHING is sized to fit inside VS Code Remote-SSH's budget, which aborts with Connecting with SSH timed out at a FIXED 17 s (from the extension's log: Using connect timeout of 17 seconds).

Setting Value Why that number
ConnectTimeout plus ConnectionAttempts 7 x 2 = 14 s worst case INSIDE the 17 s. It was 15 x 3 = 45 s, and VS Code gave up mid second attempt and reported a timeout with the host about to answer
ServerAliveInterval 15 s an encrypted keepalive holds the idle session on the SonicWall
ServerAliveCountMax 8 (~2 min) it was 20 (~5 min) and that was COUNTERPRODUCTIVE
ControlPersist 10m reopening becomes instant

Why 8 and not 20. With 5 min of tolerance the multiplexed master stayed stuck in a dead tunnel that whole time, and every new connection glued itself to it and hung along (mux_client_request_session: Broken pipe in the VS Code log). 2 min still rides out a blip; a real drop dies fast and the extension reconnects.

TCPKeepAlive = no because the kernel's keepalive drops before the deadline above, so SSH's is the one in charge.

A master stuck in a tunnel that died: ssh -O exit workstation kills the socket immediately.

The router is Dropbear, and a reflash regenerates its host key

ssh router reaches OpenWrt 25.12 / BusyBox. It accepts ed25519, but a firmware update REGENERATES the host key and the next connection aborts with REMOTE HOST IDENTIFICATION HAS CHANGED. That is the flash, not an attack: ssh-keygen -R 192.168.1.1 and accept again.

authorized_keys lives ON THE ROUTER, out of Nix's reach, so installing the key is a manual step, once per reflash: ssh-copy-id -i ~/.ssh/id_ed25519.pub v1cferr@192.168.1.1. It survives a sysupgrade with "keep settings"; a clean reflash requires repeating it.

No faiResilience here: that sizes keepalive and multiplexing for the SonicWall tunnel and for a 17s budget, and this is a LAN hop with <1ms. Inheriting it would be cargo cult.

The IP is a literal because it is the ONLY place in the repo naming the gateway (Caddy uses the /24 range, not the .1), and a lone literal does not trigger rule 11.

The mother's T480 is the second Windows, and it repeats almost none of the traps

ssh t480 reaches a ThinkPad T480 (Windows 11 IoT Enterprise LTSC 24H2) being prepared for my mother. Everything below was measured on 22/08/2026, from this side, and the interesting part is how little of the cesar section applies.

The address is the TUNNEL address, and that is not a shortcut. 10.10.10.6 is the WireGuard peer, and over there sshd_config carries ListenAddress 10.10.10.6, so there is no path to that sshd from the LAN even while the machine is still in the house. It is NOT a literal here any more: the RDP wrapper became a second consumer, so the value moved into my.t480 (home/net/t480.nix) and this block reads it, which is rule 11. It is the machine's own rule: no port of that host is exposed, and SSH, Sunshine and RDP accept the tunnel and the home LAN only.

That bind is why sshd there DEPENDS on the tunnel, and it is the sharpest thing this host has to teach. sc qc sshd gives WireGuardTunnel$mae-t480, because an address that does not exist yet cannot be bound and a Win32 service that fails to bind does not retry. The dependency is not the whole answer either: Windows guarantees the ORDER at boot, and when a dependency RESTARTS the dependent is stopped and NOT brought back. What covers that is a watchdog on the machine itself, and knowing this is what keeps "SSH died" from being investigated on this side.

No RemoteCommand, and no -cmd twin. HKLM\SOFTWARE\OpenSSH\DefaultShell over there is already C:\Program Files\Git\bin\bash.exe WITH DefaultShellCommandOption = -c, so an interactive session and ssh t480 <command> both work with nothing on the client. The cesar pair exists because that machine's shell is PowerShell, which needs the &, not because Windows demands it.

No SetEnv TERM, for the same reason as cesar: that sshd ships no AcceptEnv, so the variable dies on the server whatever the client sends.

No keepalive block, and NOT because the path is short. The server sends them (ClientAliveInterval 30, ClientAliveCountMax 4), so a client-side copy would be a second mechanism doing one job. That answers the temptation faiResilience creates every time a host is added over a link that is not a LAN hop.

The admin key trap applies, and the icacls half of it was a NO-OP here. v1cferr is an administrator there, so sshd reads only C:\ProgramData\ssh\administrators_authorized_keys, the same as cesar. But the file inherited exactly BUILTIN\Administrators:(F) and SYSTEM:(F) from that directory, which is precisely what sshd demands, so the permissions step had nothing to fix. What DID fail was running the cesar guide's icacls line inside Git Bash: MSYS rewrites /inheritance:r into a path, and on a pt-BR Windows the local groups are not named Administrators and SYSTEM. If it ever has to run there: MSYS_NO_PATHCONV=1 plus the SIDs, *S-1-5-32-544 and *S-1-5-18.

Appending the key from bash removes the UTF-16 trap by construction. >> in bash writes LF and ASCII, which is the encoding sshd wants, so the -Encoding ascii that PowerShell's Add-Content needs has no counterpart to get wrong.

The Windows side is deliberately NOT documented in this repo. That machine carries a repo of its own (~/dotfiles on the T480: numbered idempotent scripts, an inventory, a winget configure DSC file), so a guide here would be a second owner for one procedure, which is rule 14, and the copy that is not applied is the one that drifts, which is rule 16. The cesar guide exists because that machine has no repo. What belongs here is only the CONTRACT between the two: the peer address, the name the router answers, and the fact that nothing is forwarded to it.

The brother's PC is Windows 11, and that is where all the traps come from

ssh cesar reaches OpenSSH_for_Windows 9.5. This declares only the CLIENT side; the Windows-side steps live in ../guides/cesar-windows-manual-steps.md so they can be redone in minutes if that Windows gets reinstalled.

No SetEnv TERM. That sshd does not ship AcceptEnv, so the variable is discarded on the server no matter what the client sends. The reason used to be doubled up (the default shell was cmd.exe, which does not read TERM), and that half expired with the shell swap below; the AcceptEnv half is the one that was load-bearing.

The post-quantum warning is honest noise. connection is not using a post-quantum key exchange shows up on EVERY connection and is not our misconfiguration: mlkem768x25519 only exists from OpenSSH 9.9 onward, and Windows 11 (build 26200) still ships 9.5. It could be silenced with WarnWeakCrypto = "no" (which exists in our 10.4), and that is precisely why it is NOT set: silencing it per host hides the server's real lag, and the day it gets fixed would go unnoticed.

The authorized_keys file is not the one you expect. Measured on 10/08: the v1cferr over there is an ADMINISTRATOR (whoami /groups brings BUILTIN\Administrators, SID S-1-5-32-544), and that decides the file. For a member of that group the Windows sshd IGNORES ~/.ssh/authorized_keys and reads only:

Add-Content C:\ProgramData\ssh\administrators_authorized_keys '<id_ed25519.pub>'
icacls C:\ProgramData\ssh\administrators_authorized_keys /inheritance:r `
  /grant "Administrators:F" /grant "SYSTEM:F"

The icacls is not decoration: the sshd REFUSES the file, and falls back to the password silently on the client side, if anybody else can write to it. And ssh-copy-id does NOT work here, because it assumes a POSIX shell on the other end.

The RemoteCommand swaps the login shell for Git Bash. where git gives C:\Program Files\Git\cmd\git.exe, but where bash does NOT find that bash, because only Git\cmd is on the PATH while bash.exe lives in Git\bin, hence the absolute path. The only bash on the PATH is C:\Windows\System32\bash.exe, which is NOT bash: it is the legacy WSL stub, and the machine has no distro installed.

The registry shell swap was refused once, and then the owner asked for it (18/08/2026). HKLM:\SOFTWARE\OpenSSH\DefaultShell is now C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe, because he drives Claude Code from PowerShell and wanted his SSH session to land there. The old refusal is not being retracted as wrong: its reason was that a GLOBAL setting would impose a shell on the OWNER, and it is the owner who reversed the premise. Win32-OpenSSH has no per-user shell, and the per-user alternative (Match User plus ForceCommand) applies to subsystems as well, so it would have cost him scp, sftp and Remote-SSH.

DefaultShellCommandOption has to be set along with it, and forgetting it is the worst kind of half-broken: sshd hands a one-off command to the shell with /c, which is cmd's switch and which PowerShell rejects, so interactive sessions keep working while EVERY non-interactive one dies. The second value is -c, and it is what keeps ssh cesar-cmd <command> alive.

That swap is also why RemoteCommand carries a &. In PowerShell a quoted path at the start of a line is a STRING, not a command, so the old "C:\Program Files\Git\bin\bash.exe" -l -i stopped launching bash and started returning a parse error. The & call operator runs it. Both forms measured on 18/08/2026.

Why cesar-cmd exists, and it is not duplication. RemoteCommand and a command line are MUTUALLY EXCLUSIVE in ssh (Cannot execute command-line and remote command), so with the RemoteCommand block, ssh cesar <cmd>, scp and rsync STOP WORKING. The twin is the declarative way out: cesar to sit down and work, cesar-cmd to copy a file or run a one-off. The alternative was memorizing -o RemoteCommand=none on every invocation, which is exactly what the repo exists so you do not have to remember.

The IP stopped being a literal per block and became a let binding, cesarHost, the day the third block appeared (rule 11 applied INSIDE the module: no my.* option, because nothing outside this file reads it). It comes from DHCP: if the router hands out another address the alias breaks, and the fix is the reservation on the OpenWrt (dhcp.arch_cesar), not one more option.

That machine DUAL BOOTS, and known_hosts keys by name and not by address

ssh v1cferr@192.168.1.40 died with REMOTE HOST IDENTIFICATION HAS CHANGED on 25/08/2026, and it was not an attack: the machine had booted into Linux. Two operating systems, each with its OWN sshd and its own host keys (C:\ProgramData\ssh\ssh_host_* on Windows, /etc/ssh/ssh_host_* on the Linux side), answering on the SAME address and port. Measured that day, none of the three keys matched:

Windows (OpenSSH 9.5) Linux (OpenSSH 10.5)
ed25519 SHA256:PfytwEuINkNhyNE5RMzea0NkL4O1AFWuoeOhCI5CWdE SHA256:ovue+UzduT+luQHHLT3KUfOwVbbGMA949wdQJPBdUmQ
rsa SHA256:GNgSXh/w+P8nbk/eQ373Bd3DlPSQlTwj38ShskQF3Dg SHA256:KWrwbOOK0X4OD/2W0SNpxYO2fR6FjbwCUBqFnEJ7RLQ
ecdsa SHA256:MOzk0McYirsptnTn2Y4tRL8ucV4yfEDRNGmlRInuxqQ SHA256:HyD7k1eBqJwpKEYqnaPiCNf/7Bi38SwgnQdHw9a51yo

The alarm is CORRECT and cannot be tuned away. known_hosts maps a NAME (a host, an IP) to a key, so one address with two identities is, to the client, indistinguishable from somebody having taken over the address. ssh-keygen -R 192.168.1.40 "fixes" it for the OS booted right now and breaks the next reboot, forever, which is how a real hijack ends up looking like routine.

HostKeyAlias is the fix, and it is one word per block. It changes ONLY the name used to look the host key up, never the address dialed, so the three blocks keep cesarHost and split into two identities: cesar and cesar-cmd verify under cesar-windows, cesar-linux under cesar-linux. Both sets coexist in known_hosts and neither boot touches the other's line.

The price is that the raw IP is now off limits. ssh v1cferr@192.168.1.40 carries no alias, so it keeps flapping between the two keys: the alias IS the interface. Same hole on the external path, cesar-ssh.v1cferr.dev:2223, whose entry here still holds the Windows key only, and whose real client is the phone, out of this config's reach.

Asking for the wrong OS now raises the SAME warning, and that is the design. With Linux up, ssh cesar aborts with REMOTE HOST IDENTIFICATION HAS CHANGED on the cesar-windows line, and that reading is exact: the alias is a statement about WHICH identity is expected, so a mismatch is a refusal to talk to the other one. Verified in both directions on 25/08/2026: cesar-linux connected against the live keys (it only stopped at the password prompt), cesar-windows refused.

known_hosts is state, so the split was done by hand (25/08/2026): the Windows trio, already trusted, was relabeled from 192.168.1.40 to cesar-windows, and the Linux trio was seeded from ssh-keyscan piped through sed. That seed is trust-on-first-use on the LAN, NOT verification: the ed25519 above still has to be compared against ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub read on that machine. Backup at ~/.ssh/known_hosts.bak-20260825.

The key was refused by a home directory that did not exist, and that is now closed. The Linux half is Arch (arch-cesar, kernel 7.1.8, OpenSSH 10.5p1) and the same v1cferr lives there, in wheel: a password login worked on 25/08/2026 and landed on Could not chdir to home directory /home/v1cferr: No such file or directory, which is useradd without -m. That one line explained the refusal: with no /home/v1cferr there is nowhere for ~/.ssh/authorized_keys to live, so public key auth CANNOT work whatever the client sends, and ssh-copy-id cannot bootstrap it either, since it does not create the home. The order was the home first, with sudo over there (install -d -m 700 -o v1cferr -g v1cferr /home/v1cferr, then /etc/skel on top), and the key after. Verified from here the same day: key auth, and scp with no -cmd twin, because RemoteCommand is a Windows problem and this half never had it. The administrators_authorized_keys detour of the section above is Win32-OpenSSH's too: on Linux being in wheel changes nothing about WHICH file sshd reads.

The raw IP proved the split from the other side, too. Connecting to 192.168.1.40 by hand after the change did not warn about a changed key: it asked to confirm a NEW host and said This host key is known by the following other names/addresses: ~/.ssh/known_hosts:21: cesar-linux, which is ssh confirming the identity is already trusted under its alias. Answering yes there adds a bare-IP line back, and that line is the one that screams on the next Windows boot, so it was removed again. The alias is the interface; the address is an implementation detail of the alias.