Skip to content

Pin the tunnel's SSH host key so a reused connection name reconnects - #6557

Merged
anton-107 merged 8 commits into
mainfrom
deco-27882-reconnects-with-reused-names
Sep 8, 2026
Merged

Pin the tunnel's SSH host key so a reused connection name reconnects#6557
anton-107 merged 8 commits into
mainfrom
deco-27882-reconnects-with-reused-names

Conversation

@anton-107

Copy link
Copy Markdown
Contributor

Why

Reconnecting to an SSH tunnel with a connection name that was used before could
fail with Host key verification failed, even when the connection was perfectly
legitimate. A --name is a stable handle for serverless compute, not a live
session id, so the compute behind a name changes over time (a new server on the
same name once the previous one shut down). It could also collide with an entry
left in the global ~/.ssh/known_hosts by the same name in another workspace,
since a name is unique only within one workspace. Users had to run ssh-keygen -R
by hand to recover. (DECO-27882)

What

The tunnel server generates its host key once, stores it in the connection's
secret scope in the workspace, and reuses it for every sshd it launches — so
the workspace is the authority on the key. This change has the client read that
key and pin it before every connection:

  • New sshconfig.GetKnownHostsPath / PinHostKey: tunnel host keys are recorded
    in ~/.databricks/ssh-tunnel-known-hosts/<name> instead of ~/.ssh/known_hosts,
    so a name can never collide with an entry left by other compute. The file is
    owned by the CLI and rewritten (not edited) with the workspace's key before each
    connection, via an atomic write-and-rename; an already-correct pin is left alone
    so concurrent IDE connections don't race.
  • client.Run pins the key (pinServerHostKey) before the tunnel carries a byte,
    and ssh setup writes the same path into its host block.
  • StrictHostKeyChecking moves from accept-new to yes, pointed at the pinned
    file — there is no trust-on-first-use window left, since the key is always
    refreshed from the workspace immediately beforehand.
  • The host-key-mismatch hint no longer suggests ssh-keygen -R (that advice no
    longer applies); it now explains that the server presented a key the workspace
    does not know about.

Tests

  • New unit tests for PinHostKey (comment stripping, replacing a hashed entry,
    leaving a correct pin untouched, rejecting a malformed key) and GetKnownHostsPath.
  • Updated buildSSHArgs / GenerateHostConfig tests assert strict checking against
    the pinned file.
  • New acceptance test acceptance/ssh/reconnect-reused-name drives a real sshd
    (backed by the test server, whose host key now comes from the session secret
    scope) through a full handshake over a stale local entry, and asserts the stale
    key is replaced and the reconnect succeeds. Linux-only, skipped when sshd is
    absent.

Changelog fragment added under .nextchanges/cli/.

anton-107 and others added 2 commits September 7, 2026 15:44
`databricks ssh connect --name X` recorded the tunnel's host key in
`~/.ssh/known_hosts` under the connection name, with
`StrictHostKeyChecking accept-new`. A name identifies compute within one
workspace, but `~/.ssh/known_hosts` is global and keyed by name alone, so an
entry recorded for a name kept rejecting later connections that legitimately
had a different host key - reusing a name in a second workspace, or against
compute whose key was regenerated. ssh aborted with

    @@@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @@@
    Host key verification failed.

and recovery meant running `ssh-keygen -R <name>` by hand, which the CLI could
only suggest (#5645).

The server already publishes its host key to the connection's secret scope and
reuses it for every sshd it launches, so the workspace is the authority on the
key. The client now reads it from there and pins it in
`~/.databricks/ssh-tunnel-known-hosts/<name>` before every connection, pointing
ssh at that file with `StrictHostKeyChecking yes`. A key recorded for the name
earlier is replaced instead of failing the connection, and verification gets
stronger: the key is checked against what the workspace published rather than
accepted on first sight. `ssh setup` writes the same two options into its host
block, where the ProxyCommand refreshes the pin before ssh verifies it, so
`ssh <name>` and IDE remote-development sessions take the same path.

Tests: the acceptance test starts from the state a user reconnecting with a
reused name is in - a stale entry for the name - and asserts the connection
succeeds and the entry is replaced. The test server's tunnel now serves the
host key from the session's secret scope, as the real server does, instead of
generating an ephemeral one per connection.

Co-authored-by: Isaac <no-reply@databricks.com>
Two follow-ups on the host key pinning:

The acceptance test asserted on the tunnel's stderr, where the last lines
depend on which side closes the websocket first - the proxy's receiving loop
reports the closed connection only when it loses the race. It now asserts the
remote command's output (out.connect-*.txt) and routes stderr to a LOG file,
matching the sibling ssh tests. Verified stable over repeated runs.

PinHostKey leaves an already-correct file alone. Every ssh invocation refreshes
the pin through the ProxyCommand and an IDE opens several at once, so the
rewrite is nearly always a no-op - and on Windows renaming over a file another
ssh has open fails.

Co-authored-by: Isaac <no-reply@databricks.com>
@eng-dev-ecosystem-bot

eng-dev-ecosystem-bot commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

Integration test report

Commit: 2bc3149

Run: 34258495438

Env 🔄​flaky 💚​RECOVERED 🙈​SKIP ✅​pass 🙈​skip Time
💚​ aws linux 1 1 275 18 8:17
💚​ aws windows 1 1 277 16 7:22
🔄​ azure linux 1 1 1 273 18 12:31
💚​ azure windows 1 1 276 16 9:08
💚​ gcp linux 1 1 275 18 10:17
🔄​ gcp windows 2 1 1 275 16 9:21
Test Name aws linux aws windows azure linux azure windows gcp linux gcp windows
💚​ TestAccept 💚​R 💚​R 💚​R 💚​R 💚​R 💚​R
🙈​ TestAccept/ssh/connection 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S
🔄​ TestSyncFullFileSync ✅​p ✅​p ✅​p ✅​p ✅​p 🔄​f
🔄​ TestSyncIncrementalFileOverwritesFolder ✅​p ✅​p 🔄​f ✅​p ✅​p ✅​p
🔄​ TestSyncIncrementalSyncFileToPythonNotebook ✅​p ✅​p ✅​p ✅​p ✅​p 🔄​f
Top 12 slowest tests (at least 2 minutes):
duration env testname
3:38 gcp windows TestFilerWorkspaceFilesExtensionsReadDir
3:23 aws windows TestAccept
3:16 azure windows TestAccept
3:15 gcp windows TestAccept
3:11 aws windows TestFilerWorkspaceFilesExtensionsDelete
3:01 azure windows TestSyncFullFileSync
2:26 azure windows TestImportDirWithOverwriteFlag
2:17 azure linux TestFilerWorkspaceFilesExtensionsRead
2:10 azure windows TestExportDir
2:08 azure windows TestFilerReadWrite/workspace_files
2:03 gcp linux TestFilerRecursiveDelete/workspace_files
2:02 gcp windows TestFilerWorkspaceFilesExtensionsRead

Comment thread experimental/ssh/internal/setup/setup.go Outdated
@anton-107

Copy link
Copy Markdown
Contributor Author

Finding 1 - BLOCKER - #6557 breaks ssh setup + ssh <name> on dedicated clusters

After ssh setup --name --cluster , connecting with ssh fails permanently:

$ ssh bv-alias-merged
Checking cluster state...
Uploading binaries...
No RSA host key is known for bv-alias-merged and you have requested strict checking.
Host key verification failed.
ssh exit=255

Root cause. The host key is pinned under the session id, but ssh verifies against the host alias, and for a dedicated cluster those differ:

setup.go:51 - GetKnownHostsPath(ctx, opts.ClusterID) names the known_hosts file after the cluster id.

setup.go:56 - GenerateHostConfig(opts.HostName, ...) makes the ssh Host alias the user's --name, and adds StrictHostKeyChecking yes (main used accept-new with no UserKnownHostsFile).

client.go:533 - the ProxyCommand's pinServerHostKey calls PinHostKey(path, sessionID, key), so the single entry is named 0907-190337-c3qelmbu while ssh looks up bv-alias-merged.

Evidence (A/B on the same dogfood cluster, same pinned version, same flow):

origin/main : ssh setup --name bv-alias-main -> ssh bv-alias-main -> REMOTE_OK root ... exit 0
merged 4 PRs : ssh setup --name bv-alias-merged -> ssh bv-alias-merged -> Host key verification failed, exit 255

Deterministic: a second attempt fails identically (not a first-run race).
Mechanism confirmed: ssh setup --name (alias == session id) SUCCEEDS -> REMOTE_OK root, exit 0.

Impact. Affects every ssh setup user: --name is a required, user-chosen label (MarkFlagRequired("name"), no default), so it essentially never equals the cluster id. This is the documented entry point for dedicated clusters and the path IDE Remote-SSH uses via the host alias. Users who merely upgrade without re-running setup keep working (their persisted config still says accept-new); the break lands the moment they re-run ssh setup.

Not affected: serverless (alias == connection name == session id) and ssh connect --cluster / --ide (both use SessionIdentifier() as the alias, so the names match).

Suggested fix. Pin under the name ssh actually verifies: plumb the host alias into the ProxyCommand (it currently carries only --cluster) and pass it to PinHostKey; or record both names on the known_hosts line; or add a HostKeyAlias directive to the generated host config so ssh looks the key up under the session id. An acceptance test that drives a real ssh client through a setup-generated config would have caught this - the current tests never run ssh, and acceptance/ssh/reconnect-reused-name only covers the serverless shape where the two names coincide.

`ssh setup --name <alias> --cluster <id>` writes a `Host <alias>` block, but
the ProxyCommand pins the server's host key in the known_hosts file under the
session ID (the cluster ID for a dedicated cluster). With StrictHostKeyChecking
yes and no HostKeyAlias, OpenSSH looks the key up under the connect target -
the alias - so when the user-facing name differs from the cluster ID the file
has no matching entry and every connection fails host-key verification.

Emit `HostKeyAlias <session-id>` in the generated block so the key lookup uses
the exact name the entry is pinned under. GenerateHostConfig now takes the
alias and renders the directive when it is non-empty; setup passes the cluster
ID (which is both the known_hosts file name and the pinned entry's host field),
and the IDE path passes the session identifier it already pins under.

Co-authored-by: Isaac <no-reply@databricks.com>
Comment thread acceptance/ssh/reconnect-reused-name/script Outdated
Comment thread experimental/ssh/internal/client/client.go Outdated
Comment on lines +113 to +115
// Optional path to the known hosts file for this session. Defaults to
// ~/.databricks/ssh-tunnel-known-hosts/<session>. The CLI owns this file and rewrites
// it with the server's host key on every connection.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should guard this flag somehow rather than just changing the semantics in a comment. This now points to known hosts for a session, not a user. If a user passes ~/.ssh/known_hosts (or some other shared files) we will delete the contents without warning

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch - fixed in dfa498b by making the override name a directory instead of a file.

--user-known-hosts-file is now --known-hosts-dir, mirroring the SSHKeysDir option next to it, and the file inside it is always named after the session (GetKnownHostsPath(ctx, sessionID, dir), same shape as keys.GetLocalSSHKeyPath). Relocating the directory is all a caller can do, so pointing it at ~/.ssh writes ~/.ssh/<session-id> rather than truncating known_hosts - the footgun is gone by construction rather than by validation.

Two things fell out of it:

  • The resolved path no longer gets written back into ClientOptions (opts.UserKnownHostsFile, err = pinServerHostKey(...)), where the input flag doubled as the pin location - that overloading was what made the semantics ambiguous in the first place. It is threaded alongside keyPath, which Run already resolves the same way.
  • acceptance/ssh/connection/known_hosts is deleted: it described the entry accept-new used to append, and nothing appends any more. The four ssh acceptance tests pass --known-hosts-dir=known-hosts so their pins stay inside the test directory (verified locally against a real sshd - nothing lands in the real ~/.databricks/ssh-tunnel-known-hosts/).

anton-107 and others added 3 commits September 8, 2026 14:58
Co-authored-by: Russell Clarey <russell.clarey@databricks.com>
Co-authored-by: Russell Clarey <russell.clarey@databricks.com>
`--user-known-hosts-file` used to hand its path straight to ssh, which appended
to it. Now that the CLI pins the workspace's host key it rewrites the file it is
given, so the same flag pointed at `~/.ssh/known_hosts` - or any other shared
file - would replace its contents with a single tunnel entry. A comment saying
the CLI owns the file is not a guard.

Take a directory instead: `--known-hosts-dir`, mirroring the SSHKeysDir option
next to it, with the file inside it always named after the session. Relocating
the directory is then all a caller can do, so no file the CLI does not own can
be overwritten - pointing it at `~/.ssh` writes `~/.ssh/<session-id>` rather
than truncating `known_hosts`.

The resolved path also stops being written back into ClientOptions, where an
input flag doubled as the pin location; it is threaded alongside keyPath, which
Run already resolves the same way.

The acceptance tests keep their pins inside the test directory, and the
`connection` fixture goes away: it described the entry `accept-new` used to
append, and nothing appends any more.

Co-authored-by: Isaac <no-reply@databricks.com>

@rclarey rclarey left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

Comment thread .nextchanges/cli/ssh-connect-pins-host-key.md Outdated
@anton-107
anton-107 requested a review from rugpanov September 8, 2026 17:39
@anton-107
anton-107 dismissed rugpanov’s stale review September 8, 2026 17:55

fixed the changelog entry

@anton-107
anton-107 enabled auto-merge September 8, 2026 17:55
@anton-107
anton-107 added this pull request to the merge queue Sep 8, 2026
Merged via the queue into main with commit 2f1e114 Sep 8, 2026
37 checks passed
@anton-107
anton-107 deleted the deco-27882-reconnects-with-reused-names branch September 8, 2026 18:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants