New troubleshooting/networking article covering the three SSH failure modes after a fleet migration (stale hardcoded IP, Tailscale 1.98.x cold-path teardown, rebuilt-box host-key mismatch) and the durable fix (MagicDNS names + known_hosts purge + ConnectTimeout), with the WSL2 no-resolver caveat. Cross-links the existing host-key article (adds a 'when pinning the IP is wrong' callout) and adds the SUMMARY nav entry.
133 lines
4.9 KiB
Markdown
133 lines
4.9 KiB
Markdown
---
|
|
title: "SSH Alias Falls Through to MagicDNS — Host-Key Verification Failure (No `Host` Block)"
|
|
domain: selfhosting
|
|
category: troubleshooting
|
|
tags:
|
|
- ssh
|
|
- ssh-config
|
|
- tailscale
|
|
- magicdns
|
|
- known-hosts
|
|
- host-key
|
|
- troubleshooting
|
|
status: published
|
|
created: 2026-06-11
|
|
updated: 2026-06-12
|
|
---
|
|
|
|
# SSH Alias Falls Through to MagicDNS — Host-Key Verification Failure (No `Host` Block)
|
|
|
|
## The Problem
|
|
|
|
You `ssh` to a host you've reached many times before, but now it dies before any
|
|
auth happens:
|
|
|
|
```
|
|
$ ssh MyMac
|
|
ssh_askpass: exec(/usr/libexec/openssh/ssh-askpass): No such file or directory
|
|
Host key verification failed.
|
|
```
|
|
|
|
On a headless box (WSL, a server, a CI runner) there's no askpass binary, so the
|
|
prompt can't even be shown — SSH just aborts. Connecting **by Tailscale IP** works
|
|
fine:
|
|
|
|
```
|
|
$ ssh user@100.74.124.81 # works
|
|
$ ssh MyMac # Host key verification failed
|
|
```
|
|
|
|
## Why It Happens
|
|
|
|
There is **no `Host MyMac` block in `~/.ssh/config` at all** — and there never was.
|
|
The connection only ever worked by IP, or interactively (where you clicked through
|
|
the first-connect `yes` prompt without noticing).
|
|
|
|
When no `Host` block matches, SSH uses the literal argument as the hostname. With
|
|
Tailscale MagicDNS, `MyMac` (or `mymac`) resolves to the node — so the *connection*
|
|
succeeds — but the host key it presents is checked against `known_hosts` under the
|
|
name **`mymac`**, which has no entry. Meanwhile the key you actually trust is stored
|
|
under the **IP**:
|
|
|
|
```
|
|
$ ssh-keygen -F 100.74.124.81 # found — line 67
|
|
$ ssh-keygen -F mymac # nothing
|
|
```
|
|
|
|
So strict host-key checking has nothing to match, tries to prompt to accept the
|
|
"new" key, and on a headless host that prompt fails → `Host key verification failed`.
|
|
|
|
Confirm there's no block (and that `ssh -G` is just echoing defaults):
|
|
|
|
```
|
|
$ ssh -G MyMac | grep -E '^(hostname|user|port) '
|
|
hostname mymac # lowercased literal — NOT an explicit HostName
|
|
user youruser # your local username default — not from a block
|
|
port 22 # default
|
|
```
|
|
|
|
If `hostname` equals the arg you typed (just lowercased) and `user` is your local
|
|
login name, there is no matching `Host` block.
|
|
|
|
## The Fix
|
|
|
|
Add an explicit `Host` block that **pins the IP** that `known_hosts` already trusts.
|
|
This matches the convention every other host in a Tailscale fleet should follow —
|
|
pin the `100.x` address, not the MagicDNS name:
|
|
|
|
```sshconfig
|
|
Host MyMac mymac
|
|
HostName 100.74.124.81
|
|
User youruser
|
|
IdentityFile ~/.ssh/id_ed25519
|
|
```
|
|
|
|
> [!note] When pinning the IP is the *wrong* call
|
|
> Pinning the IP is right while the host is **stable**. If the box gets migrated or
|
|
> rebuilt — new Tailscale IP *and* new host key — the pin rots and `known_hosts`
|
|
> mismatches. At that point switch to **MagicDNS names** so the alias self-heals. See
|
|
> *[MagicDNS Names vs Pinned IPs for Tailscale SSH (After a Fleet Migration)](tailscale-ssh-magicdns-vs-pinned-ip-after-migration.md)*.
|
|
|
|
Now `ssh MyMac` resolves to `100.74.124.81`, whose key is in `known_hosts`, and the
|
|
check passes with no prompt. Verify non-interactively:
|
|
|
|
```
|
|
$ ssh -o BatchMode=yes MyMac 'hostname'
|
|
mymac.majorlan
|
|
```
|
|
|
|
`BatchMode=yes` disables every prompt — if it returns the hostname cleanly, the key
|
|
is trusted and a real key authenticated.
|
|
|
|
**Don't over-pin the identity.** Run `ssh -v user@<IP> true` and check the
|
|
`Will attempt key` / accepted-key lines first. A workstation often authenticates
|
|
with the *default* `id_ed25519`, not a fleet key — if `id_ed25519_fleet` isn't even
|
|
offered, don't put it in the block.
|
|
|
|
## Cleanup: Stale `known_hosts` Cruft
|
|
|
|
Drive-by `ssh` attempts leave junk entries like `mymac-2` (auto-suffixed names from
|
|
old keys). They never match anything once you pin the IP. Purge them:
|
|
|
|
```
|
|
$ ssh-keygen -R mymac-2
|
|
```
|
|
|
|
## How to Diagnose This
|
|
|
|
1. `ssh -o BatchMode=yes <alias> true` — if it fails with `Host key verification
|
|
failed` (not `Permission denied`), it's a host-key problem, not auth.
|
|
2. `ssh -G <alias> | grep -E '^(hostname|user|port) '` — if `hostname` is just your
|
|
typed arg and there's no real `HostName`, there's no `Host` block.
|
|
3. `ssh-keygen -F <name>` vs `ssh-keygen -F <ip>` — find which name actually holds
|
|
the trusted key. Pin whichever one `known_hosts` has (usually the IP).
|
|
|
|
## Why This Gotcha Is Invisible
|
|
|
|
It only surfaces on a host with **no askpass** (headless / WSL / cron). On a desktop,
|
|
the first-connect prompt appears, you hit `yes`, an entry gets written under the
|
|
MagicDNS name, and it "just works" — masking the fact that no `Host` block exists and
|
|
the IP-keyed entry is the only durable trust. Move the same config to a headless box
|
|
and the missing block becomes a hard failure. Related: SSH only applies `Host` blocks
|
|
by **literal pattern match**, so connecting by IP also skips them — see *Ansible Fails
|
|
with Permission Denied While `ssh <alias>` Works (Host Alias Bypass)*.
|