Skip to main content

ssh_mcp/ssh/
handler.rs

1//! SSH client handler implementation
2//!
3//! Implements the `russh::client::Handler` trait to handle SSH connection events.
4
5use std::io;
6use std::path::{Path, PathBuf};
7use std::sync::{Arc, Mutex};
8
9use anyhow::Context;
10use russh::keys::HashAlg;
11use tracing::{info, warn};
12
13use super::config::HostKeyCheckMode;
14
15/// Outcome of a host key check, recorded for recovery decisions in the
16/// connection layer.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum KeyCheckOutcome {
19    /// Key matched an existing known_hosts entry.
20    Accepted,
21    /// New key was learned and accepted (accept-new mode only).
22    AcceptedNew,
23    /// Key differs from the known_hosts entry (rotation or MITM).
24    KeyChanged,
25    /// Unknown key was rejected (strict mode only).
26    UnknownRejected,
27}
28
29/// SSH client handler for russh
30///
31/// This handler is used by russh to process SSH events such as server key
32/// verification.
33#[derive(Debug, Clone)]
34pub struct SshHandler {
35    host: String,
36    port: u16,
37    host_key_checking: HostKeyCheckMode,
38    known_hosts: Option<PathBuf>,
39    /// Shared state to record the key check outcome for connection-layer
40    /// recovery decisions.  Set by the connection manager before each
41    /// connect attempt; `None` when not needed (tests, default handler).
42    key_check_outcome: Option<Arc<Mutex<Option<KeyCheckOutcome>>>>,
43}
44
45impl SshHandler {
46    /// Create a new SSH handler
47    pub fn new(
48        host: impl Into<String>,
49        port: u16,
50        host_key_checking: HostKeyCheckMode,
51        known_hosts: Option<PathBuf>,
52    ) -> Self {
53        Self {
54            host: host.into(),
55            port,
56            host_key_checking,
57            known_hosts,
58            key_check_outcome: None,
59        }
60    }
61
62    fn check_known_hosts(
63        &self,
64        server_public_key: &russh::keys::PublicKey,
65    ) -> std::result::Result<bool, russh::keys::Error> {
66        if let Some(path) = &self.known_hosts {
67            russh::keys::known_hosts::check_known_hosts_path(
68                &self.host,
69                self.port,
70                server_public_key,
71                path,
72            )
73        } else {
74            russh::keys::known_hosts::check_known_hosts(&self.host, self.port, server_public_key)
75        }
76    }
77
78    fn learn_known_hosts(
79        &self,
80        server_public_key: &russh::keys::PublicKey,
81    ) -> std::result::Result<(), russh::keys::Error> {
82        if let Some(path) = &self.known_hosts {
83            russh::keys::known_hosts::learn_known_hosts_path(
84                &self.host,
85                self.port,
86                server_public_key,
87                path,
88            )
89        } else {
90            russh::keys::known_hosts::learn_known_hosts(&self.host, self.port, server_public_key)
91        }
92    }
93
94    fn host_port(&self) -> String {
95        format!("{}:{}", self.host, self.port)
96    }
97
98    fn fingerprint(server_public_key: &russh::keys::PublicKey) -> String {
99        server_public_key.fingerprint(HashAlg::Sha256).to_string()
100    }
101
102    /// Record the key check outcome into shared state if attached.
103    fn record_outcome(&self, outcome: KeyCheckOutcome) {
104        if let Some(ref state) = self.key_check_outcome
105            && let Ok(mut guard) = state.lock()
106        {
107            *guard = Some(outcome);
108        }
109    }
110
111    /// Attach shared state for recording the key check outcome.
112    ///
113    /// The connection manager calls this before each connect attempt so
114    /// that `do_connect` can inspect the outcome after a failure and
115    /// decide whether to retry (e.g. remove a stale entry on key change).
116    pub fn with_key_check_outcome(mut self, outcome: Arc<Mutex<Option<KeyCheckOutcome>>>) -> Self {
117        self.key_check_outcome = Some(outcome);
118        self
119    }
120}
121
122impl Default for SshHandler {
123    fn default() -> Self {
124        Self::new("localhost", 22, HostKeyCheckMode::No, None)
125    }
126}
127
128impl russh::client::Handler for SshHandler {
129    type Error = anyhow::Error;
130
131    async fn check_server_key(
132        &mut self,
133        server_public_key: &russh::keys::PublicKey,
134    ) -> Result<bool, Self::Error> {
135        let fingerprint = Self::fingerprint(server_public_key);
136
137        match self.host_key_checking {
138            HostKeyCheckMode::No => {
139                warn!(
140                    host = %self.host,
141                    port = self.port,
142                    fingerprint = %fingerprint,
143                    "SSH host key verification disabled"
144                );
145                self.record_outcome(KeyCheckOutcome::Accepted);
146                Ok(true)
147            }
148            HostKeyCheckMode::Yes => match self.check_known_hosts(server_public_key) {
149                Ok(true) => {
150                    self.record_outcome(KeyCheckOutcome::Accepted);
151                    Ok(true)
152                }
153                Ok(false) => {
154                    self.record_outcome(KeyCheckOutcome::UnknownRejected);
155                    Err(anyhow::anyhow!(
156                        "SSH host key verification failed for {}: unknown host key ({fingerprint}); add it to known_hosts or use --strict-host-key-checking=accept-new",
157                        self.host_port()
158                    ))
159                }
160                Err(e) => {
161                    self.record_outcome(KeyCheckOutcome::KeyChanged);
162                    Err(anyhow::anyhow!(
163                        "SSH host key verification failed for {}: {e} ({fingerprint})",
164                        self.host_port()
165                    ))
166                }
167            },
168            HostKeyCheckMode::AcceptNew => match self.check_known_hosts(server_public_key) {
169                Ok(true) => {
170                    self.record_outcome(KeyCheckOutcome::Accepted);
171                    Ok(true)
172                }
173                Ok(false) => {
174                    self.learn_known_hosts(server_public_key).with_context(|| {
175                        format!(
176                            "failed to record SSH host key for {} ({fingerprint})",
177                            self.host_port()
178                        )
179                    })?;
180                    info!(
181                        host = %self.host,
182                        port = self.port,
183                        fingerprint = %fingerprint,
184                        "Recorded new SSH host key"
185                    );
186                    self.record_outcome(KeyCheckOutcome::AcceptedNew);
187                    Ok(true)
188                }
189                Err(e) => {
190                    self.record_outcome(KeyCheckOutcome::KeyChanged);
191                    Err(anyhow::anyhow!(
192                        "SSH host key verification failed for {}: {e} ({fingerprint})",
193                        self.host_port()
194                    ))
195                }
196            },
197        }
198    }
199}
200
201// ---------------------------------------------------------------------------
202// Known-hosts entry removal
203// ---------------------------------------------------------------------------
204
205/// Remove known_hosts entries matching `host:port`.
206///
207/// Reads the file line by line, drops entries whose host-pattern field
208/// matches, and writes the result back atomically (temp file + rename).
209/// Comments, blank lines, and hashed (`|1|…`) entries are preserved.
210///
211/// Returns `Ok(())` when the file does not exist (nothing to remove).
212pub fn remove_known_hosts_entry(host: &str, port: u16, known_hosts: &Path) -> io::Result<()> {
213    let content = match std::fs::read_to_string(known_hosts) {
214        Ok(c) => c,
215        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
216        Err(e) => return Err(e),
217    };
218
219    let kept: Vec<&str> = content
220        .lines()
221        .filter(|line| !line_matches_host(line, host, port))
222        .collect();
223
224    let mut new_content = kept.join("\n");
225    if content.ends_with('\n') {
226        new_content.push('\n');
227    }
228
229    // Atomic write: temp file alongside, then rename.
230    let mut temp_path = known_hosts.as_os_str().to_owned();
231    temp_path.push(".tmp");
232    let temp_path = PathBuf::from(temp_path);
233
234    std::fs::write(&temp_path, &new_content)?;
235    std::fs::rename(&temp_path, known_hosts)?;
236
237    Ok(())
238}
239
240/// Resolve the default known_hosts path (`$HOME/.ssh/known_hosts`).
241///
242/// Returns `None` when `HOME` is not set.
243pub fn default_known_hosts_path() -> Option<PathBuf> {
244    std::env::var("HOME")
245        .ok()
246        .map(|home| PathBuf::from(home).join(".ssh").join("known_hosts"))
247}
248
249/// Check whether a single known_hosts line matches `host:port`.
250fn line_matches_host(line: &str, host: &str, port: u16) -> bool {
251    let trimmed = line.trim();
252    if trimmed.is_empty() || trimmed.starts_with('#') {
253        return false;
254    }
255    let first_field = match trimmed.split_whitespace().next() {
256        Some(f) => f,
257        None => return false,
258    };
259    for entry in first_field.split(',') {
260        let entry = entry.trim();
261        if entry.starts_with("|1|") {
262            continue; // hashed entry — cannot match by host
263        }
264        if port == 22 {
265            if entry == host {
266                return true;
267            }
268        } else {
269            let expected = format!("[{}]:{}", host, port);
270            if entry == expected {
271                return true;
272            }
273        }
274    }
275    false
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281    use russh::client::Handler as _;
282
283    const KEY_ONE: &str =
284        "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJdD7y3aLq454yWBdwLWbieU1ebz9/cu7/QEXn9OIeZJ";
285    const KEY_TWO: &str =
286        "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILIG2T/B0l0gaqj3puu510tu9N1OkQ4znY3LYuEm5zCF";
287
288    fn public_key(s: &str) -> russh::keys::PublicKey {
289        russh::keys::PublicKey::from_openssh(s).expect("test public key should parse")
290    }
291
292    #[test]
293    fn test_handler_creation() {
294        let handler = SshHandler::new("localhost", 22, HostKeyCheckMode::No, None);
295        assert!(format!("{:?}", handler).contains("SshHandler"));
296    }
297
298    #[test]
299    fn test_handler_default() {
300        let _handler: SshHandler = Default::default();
301    }
302
303    #[tokio::test]
304    async fn test_strict_rejects_unknown_host_key() {
305        let dir = tempfile::tempdir().expect("tempdir");
306        let known_hosts = dir.path().join("known_hosts");
307        let key = public_key(KEY_ONE);
308        let mut handler =
309            SshHandler::new("example.com", 22, HostKeyCheckMode::Yes, Some(known_hosts));
310
311        let err = handler
312            .check_server_key(&key)
313            .await
314            .expect_err("strict mode should reject unknown host key");
315
316        assert!(err.to_string().contains("unknown host key"));
317    }
318
319    #[tokio::test]
320    async fn test_accept_new_records_host_key_then_strict_accepts() {
321        let dir = tempfile::tempdir().expect("tempdir");
322        let known_hosts = dir.path().join("known_hosts");
323        let key = public_key(KEY_ONE);
324
325        let mut accept_new = SshHandler::new(
326            "example.com",
327            22,
328            HostKeyCheckMode::AcceptNew,
329            Some(known_hosts.clone()),
330        );
331        assert!(
332            accept_new
333                .check_server_key(&key)
334                .await
335                .expect("accept-new should record unknown host key")
336        );
337
338        let contents = std::fs::read_to_string(&known_hosts).expect("known_hosts should exist");
339        assert!(contents.contains("example.com ssh-ed25519"));
340
341        let mut strict =
342            SshHandler::new("example.com", 22, HostKeyCheckMode::Yes, Some(known_hosts));
343        assert!(
344            strict
345                .check_server_key(&key)
346                .await
347                .expect("strict mode should accept recorded host key")
348        );
349    }
350
351    #[tokio::test]
352    async fn test_accept_new_rejects_changed_host_key() {
353        let dir = tempfile::tempdir().expect("tempdir");
354        let known_hosts = dir.path().join("known_hosts");
355        let old_key = public_key(KEY_ONE);
356        let new_key = public_key(KEY_TWO);
357
358        russh::keys::known_hosts::learn_known_hosts_path("example.com", 22, &old_key, &known_hosts)
359            .expect("should write known_hosts");
360
361        let mut handler = SshHandler::new(
362            "example.com",
363            22,
364            HostKeyCheckMode::AcceptNew,
365            Some(known_hosts),
366        );
367        let err = handler
368            .check_server_key(&new_key)
369            .await
370            .expect_err("accept-new should reject changed host key");
371
372        assert!(err.to_string().contains("server key changed"));
373    }
374
375    #[test]
376    fn test_line_matches_host() {
377        // Non-standard port — bracketed form
378        assert!(line_matches_host(
379            "[127.0.0.1]:2222 ssh-ed25519 AAAA...",
380            "127.0.0.1",
381            2222
382        ));
383        assert!(!line_matches_host(
384            "[127.0.0.1]:2223 ssh-ed25519 AAAA...",
385            "127.0.0.1",
386            2222
387        ));
388
389        // Standard port 22 — plain hostname
390        assert!(line_matches_host(
391            "example.com ssh-ed25519 AAAA...",
392            "example.com",
393            22
394        ));
395        assert!(!line_matches_host(
396            "other.com ssh-ed25519 AAAA...",
397            "example.com",
398            22
399        ));
400
401        // Comma-separated multi-host entry
402        assert!(line_matches_host(
403            "host1,[127.0.0.1]:2222,host3 ssh-ed25519 AAAA...",
404            "127.0.0.1",
405            2222
406        ));
407        assert!(line_matches_host(
408            "host1,[127.0.0.1]:2222,host3 ssh-ed25519 AAAA...",
409            "host3",
410            22
411        ));
412
413        // Comments and blank lines preserved (not matched)
414        assert!(!line_matches_host("# comment line", "host", 22));
415        assert!(!line_matches_host("", "host", 22));
416        assert!(!line_matches_host("   ", "host", 22));
417
418        // Hashed entries cannot be matched by host
419        assert!(!line_matches_host(
420            "|1|c3No|base64 ssh-ed25519 AAAA...",
421            "host",
422            22
423        ));
424    }
425
426    #[test]
427    fn test_remove_known_hosts_entry() {
428        let dir = tempfile::tempdir().expect("tempdir");
429        let known_hosts = dir.path().join("known_hosts");
430        let old_key = public_key(KEY_ONE);
431
432        // Learn an entry for example.com:2222
433        russh::keys::known_hosts::learn_known_hosts_path(
434            "example.com",
435            2222,
436            &old_key,
437            &known_hosts,
438        )
439        .expect("should write known_hosts");
440
441        let before = std::fs::read_to_string(&known_hosts).expect("read");
442        assert!(before.contains("example.com"));
443
444        // Remove the entry
445        remove_known_hosts_entry("example.com", 2222, &known_hosts).expect("should remove entry");
446
447        let after = std::fs::read_to_string(&known_hosts).expect("read");
448        assert!(!after.contains("example.com"), "entry should be gone");
449
450        // Removing from a non-existent file is a no-op
451        let missing = dir.path().join("nope");
452        remove_known_hosts_entry("example.com", 2222, &missing)
453            .expect("non-existent file should be Ok");
454    }
455
456    #[tokio::test]
457    async fn test_key_change_recovery_flow() {
458        let dir = tempfile::tempdir().expect("tempdir");
459        let known_hosts = dir.path().join("known_hosts");
460        let old_key = public_key(KEY_ONE);
461        let new_key = public_key(KEY_TWO);
462
463        // 1 — learn old key
464        russh::keys::known_hosts::learn_known_hosts_path(
465            "example.com",
466            2222,
467            &old_key,
468            &known_hosts,
469        )
470        .expect("should write known_hosts");
471
472        // 2 — new key is rejected (KeyChanged)
473        let outcome = Arc::new(Mutex::new(None));
474        let mut handler = SshHandler::new(
475            "example.com",
476            2222,
477            HostKeyCheckMode::AcceptNew,
478            Some(known_hosts.clone()),
479        )
480        .with_key_check_outcome(outcome.clone());
481
482        let err = handler
483            .check_server_key(&new_key)
484            .await
485            .expect_err("changed key should be rejected");
486        assert!(err.to_string().contains("server key changed"));
487
488        {
489            let guard = outcome.lock().unwrap();
490            assert_eq!(*guard, Some(KeyCheckOutcome::KeyChanged));
491        }
492
493        // 3 — remove stale entry
494        remove_known_hosts_entry("example.com", 2222, &known_hosts).expect("should remove entry");
495
496        // 4 — new key is now accepted as a new host
497        let outcome2 = Arc::new(Mutex::new(None));
498        let mut handler2 = SshHandler::new(
499            "example.com",
500            2222,
501            HostKeyCheckMode::AcceptNew,
502            Some(known_hosts.clone()),
503        )
504        .with_key_check_outcome(outcome2.clone());
505
506        assert!(
507            handler2
508                .check_server_key(&new_key)
509                .await
510                .expect("new key should be accepted after entry removal")
511        );
512
513        {
514            let guard = outcome2.lock().unwrap();
515            assert_eq!(*guard, Some(KeyCheckOutcome::AcceptedNew));
516        }
517
518        // Verify the new key is now in known_hosts
519        let contents = std::fs::read_to_string(&known_hosts).expect("read");
520        assert!(contents.contains("example.com"));
521    }
522}