1use anyhow::{anyhow, Context, Result};
16use std::path::PathBuf;
17use std::time::Duration;
18use tokio::time;
19use tracing::{error, info, warn};
20
21use crate::ssh::client::Host;
22use crate::ssh::session::SshSession;
23
24const MAX_HOSTNAME_LENGTH: usize = 64;
30
31const TOTAL_TIMEOUT: Duration = Duration::from_secs(60);
33
34const STEP_TIMEOUT: Duration = Duration::from_secs(15);
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum KeyType {
44 Ed25519,
46}
47
48impl KeyType {
49 pub fn extension(&self) -> &'static str {
51 match self {
52 KeyType::Ed25519 => "ed25519",
53 }
54 }
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
63pub enum KeySetupStep {
64 GenerateKey = 1,
65 CopyPublicKey = 2,
66 VerifyKeyAuth = 3,
67 DisablePassword = 4,
68 ReloadSshd = 5,
69 FinalCheck = 6,
70}
71
72impl KeySetupStep {
73 pub fn all_steps() -> Vec<Self> {
75 vec![
76 Self::GenerateKey,
77 Self::CopyPublicKey,
78 Self::VerifyKeyAuth,
79 Self::DisablePassword,
80 Self::ReloadSshd,
81 Self::FinalCheck,
82 ]
83 }
84
85 pub fn description(&self) -> &'static str {
87 match self {
88 Self::GenerateKey => "Generating Ed25519 key pair",
89 Self::CopyPublicKey => "Copying public key to server",
90 Self::VerifyKeyAuth => "Verifying key authentication",
91 Self::DisablePassword => "Disabling password authentication",
92 Self::ReloadSshd => "Reloading SSH service",
93 Self::FinalCheck => "Final verification",
94 }
95 }
96}
97
98#[derive(Debug, Clone, PartialEq, Eq)]
104pub enum KeySetupState {
105 NotStarted,
107 InProgress,
109 Success,
111 PartialSuccess,
113 FailedSafe,
115 NeedsRollback,
117 RolledBack,
119}
120
121#[derive(Debug)]
123pub struct KeySetupMachine {
124 state: KeySetupState,
125 current_step: Option<KeySetupStep>,
126 has_sudo: bool,
127 password_disabled: bool,
128}
129
130impl KeySetupMachine {
131 pub fn new() -> Self {
133 Self {
134 state: KeySetupState::NotStarted,
135 current_step: None,
136 has_sudo: true,
137 password_disabled: false,
138 }
139 }
140
141 pub fn state(&self) -> &KeySetupState {
143 &self.state
144 }
145
146 pub fn set_has_sudo(&mut self, has_sudo: bool) {
148 self.has_sudo = has_sudo;
149 }
150
151 pub fn step_result(&mut self, step: KeySetupStep, result: Result<()>) {
156 self.current_step = Some(step);
157
158 match (step, result) {
159 (KeySetupStep::GenerateKey | KeySetupStep::CopyPublicKey, Err(_)) => {
161 self.state = KeySetupState::FailedSafe;
162 }
163
164 (KeySetupStep::VerifyKeyAuth, Err(_)) => {
167 self.state = KeySetupState::FailedSafe;
168 }
169 (KeySetupStep::VerifyKeyAuth, Ok(())) if !self.has_sudo => {
170 self.state = KeySetupState::PartialSuccess;
172 }
173
174 (KeySetupStep::DisablePassword, Ok(())) => {
176 self.password_disabled = true;
177 }
178 (KeySetupStep::DisablePassword, Err(_)) => {
179 self.state = KeySetupState::FailedSafe;
181 }
182
183 (KeySetupStep::ReloadSshd, Err(_)) => {
185 self.state = KeySetupState::NeedsRollback;
188 }
189
190 (KeySetupStep::FinalCheck, Err(_)) => {
193 self.state = KeySetupState::NeedsRollback;
194 }
195 (KeySetupStep::FinalCheck, Ok(())) => {
196 self.state = KeySetupState::Success;
197 }
198
199 (_, Ok(())) => {
201 self.state = KeySetupState::InProgress;
202 }
203 }
204 }
205
206 pub fn rollback_complete(&mut self) {
208 self.state = KeySetupState::RolledBack;
209 self.password_disabled = false;
210 }
211}
212
213impl Default for KeySetupMachine {
214 fn default() -> Self {
215 Self::new()
216 }
217}
218
219pub async fn generate_key_pair(host_name: &str, key_type: KeyType) -> Result<(PathBuf, PathBuf)> {
234 let sanitized = sanitize_hostname(host_name);
235 let key_filename = format!("omnyssh_{}_{}", sanitized, key_type.extension());
236
237 let ssh_dir = dirs::home_dir()
238 .ok_or_else(|| anyhow!("Cannot determine home directory"))?
239 .join(".ssh");
240
241 tokio::fs::create_dir_all(&ssh_dir)
243 .await
244 .with_context(|| format!("Failed to create {}", ssh_dir.display()))?;
245
246 #[cfg(unix)]
247 {
248 use std::os::unix::fs::PermissionsExt;
249 let perms = std::fs::Permissions::from_mode(0o700);
250 tokio::fs::set_permissions(&ssh_dir, perms)
251 .await
252 .with_context(|| format!("Failed to set permissions on {}", ssh_dir.display()))?;
253 }
254
255 let private_key_path = ssh_dir.join(&key_filename);
256 let public_key_path = ssh_dir.join(format!("{}.pub", key_filename));
257
258 if private_key_path.exists() && public_key_path.exists() {
260 info!(
261 "Key already exists for {}, reusing: {}",
262 host_name,
263 private_key_path.display()
264 );
265 return Ok((private_key_path, public_key_path));
266 }
267
268 info!(
271 "Generating {} key pair for {}",
272 key_type.extension(),
273 host_name
274 );
275
276 let key_type_arg = match key_type {
277 KeyType::Ed25519 => "ed25519",
278 };
279
280 let mut keygen_cmd = tokio::process::Command::new("ssh-keygen");
281 keygen_cmd
282 .arg("-t")
283 .arg(key_type_arg)
284 .arg("-f")
285 .arg(&private_key_path)
286 .arg("-N")
287 .arg("") .arg("-C")
289 .arg(format!("omnyssh-{}", host_name)); let keygen_output = keygen_cmd
292 .output()
293 .await
294 .context("Failed to run ssh-keygen for key generation")?;
295
296 if !keygen_output.status.success() {
297 let stderr = String::from_utf8_lossy(&keygen_output.stderr);
298 return Err(anyhow!("ssh-keygen failed to generate key: {}", stderr));
299 }
300
301 #[cfg(unix)]
303 {
304 use std::os::unix::fs::PermissionsExt;
305 let perms = std::fs::Permissions::from_mode(0o600);
306 tokio::fs::set_permissions(&private_key_path, perms)
307 .await
308 .with_context(|| {
309 format!(
310 "Failed to set permissions on private key {}",
311 private_key_path.display()
312 )
313 })?;
314 }
315
316 info!(
317 "Generated key pair:\n Private: {}\n Public: {}",
318 private_key_path.display(),
319 public_key_path.display()
320 );
321
322 Ok((private_key_path, public_key_path))
323}
324
325pub fn sanitize_hostname(hostname: &str) -> String {
341 if hostname.is_empty() {
342 return "unnamed_host".to_string();
343 }
344
345 let sanitized: String = hostname
346 .chars()
347 .map(|c| {
348 if c.is_alphanumeric() || c == '-' || c == '_' {
351 c
352 } else {
353 '_'
354 }
355 })
356 .take(MAX_HOSTNAME_LENGTH)
357 .collect();
358
359 if sanitized.is_empty() {
360 "unnamed_host".to_string()
361 } else {
362 sanitized
363 }
364}
365
366pub fn build_authorized_keys_command(public_key: &str) -> String {
375 let public_key = public_key.trim();
377 if public_key.contains('\n') || public_key.contains('\r') || public_key.contains('\0') {
378 panic!("Public key file contains invalid characters");
379 }
380 if !public_key.starts_with("ssh-ed25519 ")
381 && !public_key.starts_with("ssh-rsa ")
382 && !public_key.starts_with("ecdsa-sha2-")
383 {
384 panic!("Public key file has unrecognized key type");
385 }
386
387 let escaped_key = public_key.replace('\'', "'\\''");
389
390 format!(
391 r#"mkdir -p ~/.ssh && chmod 700 ~/.ssh && \
392 echo '{escaped_key}' >> ~/.ssh/authorized_keys && \
393 chmod 600 ~/.ssh/authorized_keys"#
394 )
395}
396
397pub fn build_disable_password_command() -> String {
426 let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
427 let password = force_sshd_directive("PasswordAuthentication", "no");
428 let challenge = force_sshd_directive("ChallengeResponseAuthentication", "no");
429 let kbd = force_sshd_directive("KbdInteractiveAuthentication", "no");
430 let pam = force_sshd_directive("UsePAM", "no");
431
432 format!(
433 r#"sudo -n true 2>/dev/null || {{ echo "OMNYSSH_NO_SUDO"; exit 1; }}; \
434 sudo cp /etc/ssh/sshd_config /etc/ssh/sshd_config.omnyssh_backup.{timestamp} && \
435 sudo sed -i.bak 's|^Include /etc/ssh/sshd_config.d/|#Include /etc/ssh/sshd_config.d/|' /etc/ssh/sshd_config && \
436 {password} && \
437 {challenge} && \
438 {kbd} && \
439 {pam} && \
440 sudo sshd -t || {{ echo "OMNYSSH_CONFIG_ERROR"; sudo cp /etc/ssh/sshd_config.omnyssh_backup.{timestamp} /etc/ssh/sshd_config; exit 1; }}"#
441 )
442}
443
444fn force_sshd_directive(directive: &str, value: &str) -> String {
450 assert!(
451 directive
452 .chars()
453 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'),
454 "directive contains unsafe characters: {directive}"
455 );
456 assert!(
457 value
458 .chars()
459 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == ' '),
460 "value contains unsafe characters: {value}"
461 );
462 format!(
463 r#"sudo sed -i.bak 's/^#\?{directive}.*/{directive} {value}/' /etc/ssh/sshd_config && \
464 {{ sudo grep -qE '^{directive}[[:space:]]' /etc/ssh/sshd_config || \
465 sudo sed -i '1i {directive} {value}' /etc/ssh/sshd_config; }}"#
466 )
467}
468
469pub fn build_reload_sshd_command() -> String {
473 r#"if command -v systemctl &>/dev/null; then \
474 sudo systemctl reload sshd 2>/dev/null || sudo systemctl reload ssh 2>/dev/null; \
475 elif command -v service &>/dev/null; then \
476 sudo service sshd reload 2>/dev/null || sudo service ssh reload 2>/dev/null; \
477 else \
478 echo "OMNYSSH_NO_INIT_SYSTEM"; exit 1; \
479 fi"#
480 .to_string()
481}
482
483pub fn build_rollback_command() -> String {
487 r#"BACKUP=$(find /etc/ssh -maxdepth 1 -name 'sshd_config.omnyssh_backup.*' 2>/dev/null | sort | tail -1); \
488 if [ -n "$BACKUP" ]; then \
489 sudo cp "$BACKUP" /etc/ssh/sshd_config && \
490 (sudo systemctl reload sshd 2>/dev/null || sudo systemctl reload ssh 2>/dev/null || sudo service sshd reload 2>/dev/null || sudo service ssh reload); \
491 else \
492 echo "OMNYSSH_NO_BACKUP"; exit 1; \
493 fi"#
494 .to_string()
495}
496
497pub async fn setup_key_for_host(
510 host: &Host,
511 password_session: &SshSession,
512 key_type: KeyType,
513 progress_tx: Option<tokio::sync::mpsc::Sender<KeySetupStep>>,
514) -> Result<KeySetupResult> {
515 let mut machine = KeySetupMachine::new();
516 let mut result = KeySetupResult {
517 key_path: PathBuf::new(),
518 state: KeySetupState::NotStarted,
519 error_message: None,
520 };
521
522 match time::timeout(
524 TOTAL_TIMEOUT,
525 setup_key_internal(host, password_session, key_type, &mut machine, progress_tx),
526 )
527 .await
528 {
529 Ok(Ok(key_path)) => {
530 result.key_path = key_path;
531 result.state = machine.state().clone();
532 Ok(result)
533 }
534 Ok(Err(e)) => {
535 error!("Key setup failed for {}: {}", host.name, e);
536 result.state = machine.state().clone();
537 result.error_message = Some(format!("{:#}", e));
538
539 if matches!(machine.state(), KeySetupState::NeedsRollback) {
541 if let Err(rollback_err) = emergency_rollback(password_session).await {
542 error!("Rollback failed: {}", rollback_err);
543 result.error_message = Some(format!(
544 "Setup failed AND rollback failed: {}\nRollback error: {}",
545 e, rollback_err
546 ));
547 } else {
548 machine.rollback_complete();
549 result.state = KeySetupState::RolledBack;
550 }
551 }
552
553 Err(e)
554 }
555 Err(_) => {
556 let err = anyhow!(
557 "Key setup timed out after {} seconds",
558 TOTAL_TIMEOUT.as_secs()
559 );
560 result.error_message = Some(err.to_string());
561 result.state = KeySetupState::FailedSafe;
562 Err(err)
563 }
564 }
565}
566
567async fn setup_key_internal(
569 host: &Host,
570 password_session: &SshSession,
571 key_type: KeyType,
572 machine: &mut KeySetupMachine,
573 progress_tx: Option<tokio::sync::mpsc::Sender<KeySetupStep>>,
574) -> Result<PathBuf> {
575 info!("Step 1/6: Generating key pair for {}", host.name);
577 if let Some(ref tx) = progress_tx {
578 let _ = tx.send(KeySetupStep::GenerateKey).await;
579 }
580 let (private_key_path, public_key_path) = match generate_key_pair(&host.name, key_type).await {
581 Ok(paths) => {
582 machine.step_result(KeySetupStep::GenerateKey, Ok(()));
583 paths
584 }
585 Err(e) => {
586 machine.step_result(KeySetupStep::GenerateKey, Err(anyhow!("Generation failed")));
587 return Err(e);
588 }
589 };
590
591 info!("Step 2/6: Copying public key to server");
593 if let Some(ref tx) = progress_tx {
594 let _ = tx.send(KeySetupStep::CopyPublicKey).await;
595 }
596 let public_key_content = tokio::fs::read_to_string(&public_key_path)
597 .await
598 .context("Failed to read public key file")?;
599
600 let public_key_trimmed = public_key_content.trim();
602 if public_key_trimmed.contains('\n')
603 || public_key_trimmed.contains('\r')
604 || public_key_trimmed.contains('\0')
605 {
606 anyhow::bail!("Public key file contains invalid characters");
607 }
608 if !public_key_trimmed.starts_with("ssh-ed25519 ")
609 && !public_key_trimmed.starts_with("ssh-rsa ")
610 && !public_key_trimmed.starts_with("ecdsa-sha2-")
611 {
612 anyhow::bail!("Public key file has unrecognized key type");
613 }
614
615 let copy_cmd = build_authorized_keys_command(&public_key_content);
616 match time::timeout(STEP_TIMEOUT, password_session.run_command(©_cmd)).await {
617 Ok(Ok(_)) => {
618 machine.step_result(KeySetupStep::CopyPublicKey, Ok(()));
619 }
620 Ok(Err(e)) => {
621 machine.step_result(KeySetupStep::CopyPublicKey, Err(anyhow!("Copy failed")));
622 return Err(anyhow!("Failed to copy public key to server: {}", e));
623 }
624 Err(_) => {
625 machine.step_result(KeySetupStep::CopyPublicKey, Err(anyhow!("Copy failed")));
626 return Err(anyhow!("Failed to copy public key to server: timeout"));
627 }
628 }
629
630 info!("Step 3/6: Verifying key authentication");
632 if let Some(ref tx) = progress_tx {
633 let _ = tx.send(KeySetupStep::VerifyKeyAuth).await;
634 }
635 let mut test_host = host.clone();
636 test_host.identity_file = Some(private_key_path.to_string_lossy().to_string());
637 test_host.password = None; match time::timeout(STEP_TIMEOUT, SshSession::connect(&test_host)).await {
640 Ok(Ok(test_session)) => {
641 info!("Key authentication verified successfully!");
642 test_session.disconnect().await;
643 machine.step_result(KeySetupStep::VerifyKeyAuth, Ok(()));
644 }
645 Ok(Err(e)) => {
646 machine.step_result(KeySetupStep::VerifyKeyAuth, Err(anyhow!("Verify failed")));
647 return Err(anyhow!(
648 "Key authentication verification failed: {}. Password NOT disabled.",
649 e
650 ));
651 }
652 Err(_) => {
653 machine.step_result(KeySetupStep::VerifyKeyAuth, Err(anyhow!("Verify failed")));
654 return Err(anyhow!(
655 "Key authentication verification timed out. Password NOT disabled."
656 ));
657 }
658 }
659
660 info!("Checking sudo availability");
663 match password_session
664 .run_command_checked("sudo -n true 2>/dev/null")
665 .await
666 {
667 Ok(_) => {
668 info!("Sudo access confirmed");
669 }
670 Err(_) => {
671 warn!("No sudo access — password authentication will NOT be disabled");
672 machine.set_has_sudo(false);
673 machine.step_result(KeySetupStep::VerifyKeyAuth, Ok(())); return Ok(private_key_path);
675 }
676 }
677
678 info!("Step 4/6: Disabling password authentication");
680 if let Some(ref tx) = progress_tx {
681 let _ = tx.send(KeySetupStep::DisablePassword).await;
682 }
683 let disable_cmd = build_disable_password_command();
684 match time::timeout(STEP_TIMEOUT, password_session.run_command(&disable_cmd)).await {
685 Ok(Ok(output)) if output.contains("OMNYSSH_NO_SUDO") => {
686 machine.set_has_sudo(false);
687 machine.step_result(KeySetupStep::VerifyKeyAuth, Ok(()));
688 return Ok(private_key_path);
689 }
690 Ok(Ok(output)) if output.contains("OMNYSSH_CONFIG_ERROR") => {
691 machine.step_result(KeySetupStep::DisablePassword, Err(anyhow!("Config error")));
692 return Err(anyhow!("sshd config validation failed. Backup restored."));
693 }
694 Ok(Ok(_)) => {
695 machine.step_result(KeySetupStep::DisablePassword, Ok(()));
696 }
697 Ok(Err(e)) => {
698 machine.step_result(
699 KeySetupStep::DisablePassword,
700 Err(anyhow!("Disable failed")),
701 );
702 return Err(anyhow!("Failed to disable password authentication: {}", e));
703 }
704 Err(_) => {
705 machine.step_result(
706 KeySetupStep::DisablePassword,
707 Err(anyhow!("Disable failed")),
708 );
709 return Err(anyhow!(
710 "Failed to disable password authentication: timeout"
711 ));
712 }
713 }
714
715 info!("Step 5/6: Reloading SSH daemon");
717 if let Some(ref tx) = progress_tx {
718 let _ = tx.send(KeySetupStep::ReloadSshd).await;
719 }
720 let reload_cmd = build_reload_sshd_command();
721 match time::timeout(STEP_TIMEOUT, password_session.run_command(&reload_cmd)).await {
722 Ok(Ok(_)) => {
723 machine.step_result(KeySetupStep::ReloadSshd, Ok(()));
724 }
725 Ok(Err(e)) => {
726 machine.step_result(KeySetupStep::ReloadSshd, Err(anyhow!("Reload failed")));
727 return Err(anyhow!("Failed to reload SSH daemon: {}", e));
728 }
729 Err(_) => {
730 machine.step_result(KeySetupStep::ReloadSshd, Err(anyhow!("Reload failed")));
731 return Err(anyhow!("Failed to reload SSH daemon: timeout"));
732 }
733 }
734
735 info!("Step 6/6: Final verification");
737 if let Some(ref tx) = progress_tx {
738 let _ = tx.send(KeySetupStep::FinalCheck).await;
739 }
740 match time::timeout(STEP_TIMEOUT, SshSession::connect(&test_host)).await {
741 Ok(Ok(final_session)) => {
742 info!("Final verification passed! Key setup complete.");
743 final_session.disconnect().await;
744 machine.step_result(KeySetupStep::FinalCheck, Ok(()));
745 Ok(private_key_path)
746 }
747 Ok(Err(e)) => {
748 error!(
749 "Final check failed: {}. Password is disabled but key doesn't work!",
750 e
751 );
752 machine.step_result(KeySetupStep::FinalCheck, Err(anyhow!("Final check failed")));
753 Err(anyhow!(
754 "Final verification failed after disabling password. Attempting rollback."
755 ))
756 }
757 Err(_) => {
758 error!("Final check timed out. Password is disabled but key doesn't work!");
759 machine.step_result(KeySetupStep::FinalCheck, Err(anyhow!("Final check failed")));
760 Err(anyhow!(
761 "Final verification timed out after disabling password. Attempting rollback."
762 ))
763 }
764 }
765}
766
767async fn emergency_rollback(session: &SshSession) -> Result<()> {
769 warn!("Attempting emergency rollback of sshd_config");
770 let rollback_cmd = build_rollback_command();
771
772 match time::timeout(STEP_TIMEOUT, session.run_command(&rollback_cmd)).await {
773 Ok(Ok(output)) if output.contains("OMNYSSH_NO_BACKUP") => {
774 Err(anyhow!("No backup file found for rollback"))
775 }
776 Ok(Ok(_)) => {
777 info!("Rollback successful — password authentication restored");
778 Ok(())
779 }
780 Ok(Err(e)) => Err(anyhow!("Rollback failed: {}", e)),
781 Err(_) => Err(anyhow!("Rollback failed: timeout")),
782 }
783}
784
785#[derive(Debug, Clone)]
791pub struct KeySetupResult {
792 pub key_path: PathBuf,
794 pub state: KeySetupState,
796 pub error_message: Option<String>,
798}
799
800#[cfg(test)]
805mod tests {
806 use super::*;
807
808 #[test]
809 fn test_sanitize_hostname() {
810 assert_eq!(sanitize_hostname("web-prod-1"), "web-prod-1");
811 assert_eq!(sanitize_hostname("my server (prod)"), "my_server__prod_");
812 assert_eq!(sanitize_hostname("../../etc/passwd"), "______etc_passwd");
813 assert_eq!(sanitize_hostname(""), "unnamed_host");
814 assert_eq!(sanitize_hostname("a/b\\c:d*e?f"), "a_b_c_d_e_f");
815
816 let long_name = "a".repeat(100);
818 assert_eq!(sanitize_hostname(&long_name).len(), MAX_HOSTNAME_LENGTH);
819 }
820
821 #[tokio::test]
822 #[ignore] async fn test_key_generation_and_conversion() {
824 let test_host = "test_conversion_host";
826 let result = generate_key_pair(test_host, KeyType::Ed25519).await;
827
828 assert!(result.is_ok(), "Key generation should succeed");
829 let (private_key_path, public_key_path) = result.unwrap();
830
831 assert!(private_key_path.exists(), "Private key file should exist");
833 assert!(public_key_path.exists(), "Public key file should exist");
834
835 let private_key_content = tokio::fs::read_to_string(&private_key_path).await.unwrap();
837 let first_line = private_key_content.lines().next().unwrap();
838
839 assert!(
841 first_line.contains("BEGIN OPENSSH PRIVATE KEY")
842 || first_line.contains("BEGIN PRIVATE KEY"),
843 "Key should be in OpenSSH or PKCS#8 format, got: {}",
844 first_line
845 );
846
847 let output = tokio::process::Command::new("ssh-keygen")
849 .args(["-y", "-f"])
850 .arg(&private_key_path)
851 .output()
852 .await
853 .unwrap();
854
855 assert!(
856 output.status.success(),
857 "ssh-keygen should be able to read the generated key. stderr: {}",
858 String::from_utf8_lossy(&output.stderr)
859 );
860
861 let _ = tokio::fs::remove_file(&private_key_path).await;
863 let _ = tokio::fs::remove_file(&public_key_path).await;
864 }
865
866 #[test]
867 fn test_key_setup_step_ordering() {
868 let steps = KeySetupStep::all_steps();
869 assert_eq!(steps[0], KeySetupStep::GenerateKey);
870 assert_eq!(steps[5], KeySetupStep::FinalCheck);
871 assert_eq!(steps.len(), 6);
872 }
873
874 #[test]
875 fn test_key_setup_machine_verify_failure_stops_process() {
876 let mut machine = KeySetupMachine::new();
877 machine.step_result(KeySetupStep::GenerateKey, Ok(()));
878 machine.step_result(KeySetupStep::CopyPublicKey, Ok(()));
879 machine.step_result(
880 KeySetupStep::VerifyKeyAuth,
881 Err(anyhow!("connection refused")),
882 );
883
884 assert_eq!(machine.state(), &KeySetupState::FailedSafe);
886 assert!(!machine.password_disabled);
887 }
888
889 #[test]
890 fn test_key_setup_machine_no_sudo_partial_success() {
891 let mut machine = KeySetupMachine::new();
892 machine.set_has_sudo(false);
893
894 machine.step_result(KeySetupStep::GenerateKey, Ok(()));
895 machine.step_result(KeySetupStep::CopyPublicKey, Ok(()));
896 machine.step_result(KeySetupStep::VerifyKeyAuth, Ok(()));
897
898 assert_eq!(machine.state(), &KeySetupState::PartialSuccess);
900 assert!(!machine.password_disabled);
901 }
902
903 #[test]
904 fn test_key_setup_machine_final_check_failure_triggers_rollback() {
905 let mut machine = KeySetupMachine::new();
906 machine.step_result(KeySetupStep::GenerateKey, Ok(()));
907 machine.step_result(KeySetupStep::CopyPublicKey, Ok(()));
908 machine.step_result(KeySetupStep::VerifyKeyAuth, Ok(()));
909 machine.step_result(KeySetupStep::DisablePassword, Ok(()));
910 machine.step_result(KeySetupStep::ReloadSshd, Ok(()));
911 machine.step_result(KeySetupStep::FinalCheck, Err(anyhow!("timeout")));
912
913 assert_eq!(machine.state(), &KeySetupState::NeedsRollback);
915 assert!(machine.password_disabled);
916 }
917
918 #[test]
919 fn test_authorized_keys_command_escapes_quotes() {
920 let pubkey = "ssh-ed25519 AAAA... user's key";
921 let cmd = build_authorized_keys_command(pubkey);
922
923 assert!(cmd.contains("user'\\''s"));
925 assert!(cmd.contains(">> ~/.ssh/authorized_keys"));
927 assert!(!cmd.contains(" > ~/.ssh/authorized_keys"));
929 }
930
931 #[test]
932 fn test_disable_password_command_creates_backup() {
933 let cmd = build_disable_password_command();
934
935 assert!(cmd.contains("omnyssh_backup."));
937 assert!(cmd.contains("sshd -t"));
939 assert!(
941 cmd.contains("'s|^Include /etc/ssh/sshd_config.d/|#Include /etc/ssh/sshd_config.d/|'")
942 );
943 assert!(cmd.contains("PasswordAuthentication no"));
945 assert!(cmd.contains("ChallengeResponseAuthentication no"));
946 assert!(cmd.contains("KbdInteractiveAuthentication no"));
947 assert!(cmd.contains("UsePAM no"));
949 }
950
951 #[test]
952 fn test_disable_password_command_adds_missing_directives() {
953 let cmd = build_disable_password_command();
954
955 assert!(cmd.contains("grep -qE '^PasswordAuthentication[[:space:]]'"));
957 assert!(cmd.contains("sed -i '1i PasswordAuthentication no'"));
958 assert!(cmd.contains("grep -qE '^UsePAM[[:space:]]'"));
959 assert!(cmd.contains("sed -i '1i UsePAM no'"));
960 }
961
962 #[test]
963 fn test_reload_sshd_uses_reload_not_restart() {
964 let cmd = build_reload_sshd_command();
965
966 assert!(cmd.contains("reload"));
968 assert!(!cmd.contains("restart"));
969 }
970}