1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum KeyCheckOutcome {
19 Accepted,
21 AcceptedNew,
23 KeyChanged,
25 UnknownRejected,
27}
28
29#[derive(Debug, Clone)]
34pub struct SshHandler {
35 host: String,
36 port: u16,
37 host_key_checking: HostKeyCheckMode,
38 known_hosts: Option<PathBuf>,
39 key_check_outcome: Option<Arc<Mutex<Option<KeyCheckOutcome>>>>,
43}
44
45impl SshHandler {
46 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 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 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
201pub 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 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
240pub 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
249fn 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; }
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 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 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 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 assert!(!line_matches_host("# comment line", "host", 22));
415 assert!(!line_matches_host("", "host", 22));
416 assert!(!line_matches_host(" ", "host", 22));
417
418 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 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_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 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 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 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 remove_known_hosts_entry("example.com", 2222, &known_hosts).expect("should remove entry");
495
496 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 let contents = std::fs::read_to_string(&known_hosts).expect("read");
520 assert!(contents.contains("example.com"));
521 }
522}