1use std::collections::HashMap;
7use std::os::unix::io::RawFd;
8use std::time::Duration;
9
10use crate::worker::exec::ExecWorker;
11use crate::worker::{EventHandler, Worker, WorkerState};
12
13#[derive(Debug, Clone)]
15pub struct SshOptions {
16 pub ssh_path: String,
18 pub scp_path: String,
20 pub user: Option<String>,
22 pub options: Vec<String>,
24 pub connect_timeout: Option<u32>,
26 pub strict_host_key_checking: bool,
28 pub password_auth: bool,
30 pub identity_file: Option<String>,
32 pub config_file: Option<String>,
34 pub control_master: Option<ControlMaster>,
36 pub control_path: Option<String>,
38 pub control_persist: Option<String>,
40 pub proxy_jump: Option<String>,
42 pub port: Option<u16>,
44}
45
46#[derive(Debug, Clone, PartialEq)]
48pub enum ControlMaster {
49 Auto,
51 AutoAsk,
53 Yes,
55 No,
57}
58
59impl Default for SshOptions {
60 fn default() -> Self {
61 Self {
62 ssh_path: "ssh".to_string(),
63 scp_path: "scp".to_string(),
64 user: None,
65 options: Vec::new(),
66 connect_timeout: Some(10),
67 strict_host_key_checking: false,
68 password_auth: false,
69 identity_file: None,
70 config_file: None,
71 control_master: None,
72 control_path: None,
73 control_persist: None,
74 proxy_jump: None,
75 port: None,
76 }
77 }
78}
79
80impl SshOptions {
81 pub fn build_ssh_cmd(&self, node: &str, command: &str) -> String {
85 let mut parts = Vec::new();
86 parts.push(self.ssh_path.clone());
87
88 if let Some(ref config_file) = self.config_file {
90 parts.push("-F".to_string());
91 parts.push(config_file.clone());
92 }
93
94 parts.push("-oForwardX11=no".to_string());
96
97 if !self.strict_host_key_checking {
98 parts.push("-oStrictHostKeyChecking=no".to_string());
99 }
100
101 if !self.password_auth {
102 parts.push("-oPasswordAuthentication=no".to_string());
103 }
104
105 if let Some(timeout) = self.connect_timeout {
106 parts.push(format!("-oConnectTimeout={}", timeout));
107 }
108
109 if let Some(ref cm) = self.control_master {
111 let val = match cm {
112 ControlMaster::Auto => "auto",
113 ControlMaster::AutoAsk => "autoask",
114 ControlMaster::Yes => "yes",
115 ControlMaster::No => "no",
116 };
117 parts.push(format!("-oControlMaster={}", val));
118 }
119 if let Some(ref cp) = self.control_path {
120 parts.push(format!("-oControlPath={}", cp));
121 }
122 if let Some(ref persist) = self.control_persist {
123 parts.push(format!("-oControlPersist={}", persist));
124 }
125
126 for opt in &self.options {
128 parts.push(format!("-o{}", opt));
129 }
130
131 if let Some(ref identity) = self.identity_file {
133 parts.push("-i".to_string());
134 parts.push(identity.clone());
135 }
136
137 if let Some(ref jump) = self.proxy_jump {
139 parts.push("-J".to_string());
140 parts.push(jump.clone());
141 }
142
143 if let Some(port) = self.port {
145 parts.push("-p".to_string());
146 parts.push(port.to_string());
147 }
148
149 if let Some(ref user) = self.user {
151 parts.push("-l".to_string());
152 parts.push(user.clone());
153 }
154
155 parts.push(node.to_string());
157 parts.push(command.to_string());
158
159 parts.join(" ")
160 }
161
162 pub fn build_scp_cmd(
166 &self,
167 node: &str,
168 source: &str,
169 dest: &str,
170 reverse: bool,
171 preserve: bool,
172 directory: bool,
173 ) -> String {
174 let mut parts = Vec::new();
175 parts.push(self.scp_path.clone());
176
177 if let Some(ref config_file) = self.config_file {
179 parts.push("-F".to_string());
180 parts.push(config_file.clone());
181 }
182
183 if !self.strict_host_key_checking {
184 parts.push("-oStrictHostKeyChecking=no".to_string());
185 }
186
187 if !self.password_auth {
188 parts.push("-oPasswordAuthentication=no".to_string());
189 }
190
191 if let Some(timeout) = self.connect_timeout {
192 parts.push(format!("-oConnectTimeout={}", timeout));
193 }
194
195 if let Some(ref cm) = self.control_master {
197 let val = match cm {
198 ControlMaster::Auto => "auto",
199 ControlMaster::AutoAsk => "autoask",
200 ControlMaster::Yes => "yes",
201 ControlMaster::No => "no",
202 };
203 parts.push(format!("-oControlMaster={}", val));
204 }
205 if let Some(ref cp) = self.control_path {
206 parts.push(format!("-oControlPath={}", cp));
207 }
208 if let Some(ref persist) = self.control_persist {
209 parts.push(format!("-oControlPersist={}", persist));
210 }
211
212 if preserve {
213 parts.push("-p".to_string());
214 }
215
216 if directory {
217 parts.push("-r".to_string());
218 }
219
220 if let Some(ref identity) = self.identity_file {
222 parts.push("-i".to_string());
223 parts.push(identity.clone());
224 }
225
226 if let Some(ref jump) = self.proxy_jump {
228 parts.push("-J".to_string());
229 parts.push(jump.clone());
230 }
231
232 if let Some(port) = self.port {
234 parts.push("-P".to_string()); parts.push(port.to_string());
236 }
237
238 for opt in &self.options {
240 parts.push(format!("-o{}", opt));
241 }
242
243 let user_prefix = if let Some(ref user) = self.user {
245 format!("{}@", user)
246 } else {
247 String::new()
248 };
249
250 if reverse {
251 parts.push(format!("{}{}:{}", user_prefix, node, source));
253 parts.push(dest.to_string());
254 } else {
255 parts.push(source.to_string());
257 parts.push(format!("{}{}:{}", user_prefix, node, dest));
258 }
259
260 parts.join(" ")
261 }
262}
263
264pub struct SshWorker {
270 inner: ExecWorker,
272 ssh_options: SshOptions,
274 original_command: String,
276}
277
278impl SshWorker {
279 pub fn new(
288 nodes: Vec<String>,
289 command: String,
290 fanout: usize,
291 timeout: Option<Duration>,
292 ssh_options: SshOptions,
293 ) -> Self {
294 let ssh_cmd = ssh_options.build_ssh_cmd("%h", &command);
297
298 SshWorker {
299 inner: ExecWorker::new(nodes, ssh_cmd, fanout, timeout),
300 ssh_options,
301 original_command: command,
302 }
303 }
304
305 pub fn with_defaults(
307 nodes: Vec<String>,
308 command: String,
309 fanout: usize,
310 timeout: Option<Duration>,
311 ) -> Self {
312 Self::new(nodes, command, fanout, timeout, SshOptions::default())
313 }
314
315 pub fn with_stderr(mut self, stderr: bool) -> Self {
317 self.inner = self.inner.with_stderr(stderr);
318 self
319 }
320
321 pub fn ssh_options(&self) -> &SshOptions {
323 &self.ssh_options
324 }
325
326 pub fn original_command(&self) -> &str {
328 &self.original_command
329 }
330}
331
332impl Worker for SshWorker {
333 fn start(&mut self) -> crate::worker::Result<()> {
334 self.inner.start()
335 }
336
337 fn abort(&mut self, kill: bool) {
338 self.inner.abort(kill);
339 }
340
341 fn state(&self) -> WorkerState {
342 self.inner.state()
343 }
344
345 fn set_handler(&mut self, handler: Box<dyn EventHandler>) {
346 self.inner.set_handler(handler);
347 }
348
349 fn read_fds(&self) -> Vec<RawFd> {
350 self.inner.read_fds()
351 }
352
353 fn write_fds(&self) -> Vec<RawFd> {
354 self.inner.write_fds()
355 }
356
357 fn handle_read(&mut self, fd: RawFd) -> crate::worker::Result<()> {
358 self.inner.handle_read(fd)
359 }
360
361 fn handle_write(&mut self, fd: RawFd) -> crate::worker::Result<()> {
362 self.inner.handle_write(fd)
363 }
364
365 fn is_done(&self) -> bool {
366 self.inner.is_done()
367 }
368
369 fn retcodes(&self) -> &HashMap<String, i32> {
370 self.inner.retcodes()
371 }
372
373 fn num_nodes(&self) -> usize {
374 self.inner.num_nodes()
375 }
376
377 fn take_handler(&mut self) -> Option<Box<dyn EventHandler>> {
378 self.inner.take_handler()
379 }
380}
381
382pub struct ScpWorker {
386 inner: ExecWorker,
388 #[allow(dead_code)]
390 ssh_options: SshOptions,
391 source: String,
393 dest: String,
395 reverse: bool,
397}
398
399impl ScpWorker {
400 #[allow(clippy::too_many_arguments)]
413 pub fn new(
414 nodes: Vec<String>,
415 source: String,
416 dest: String,
417 fanout: usize,
418 timeout: Option<Duration>,
419 ssh_options: SshOptions,
420 reverse: bool,
421 preserve: bool,
422 directory: bool,
423 ) -> Self {
424 let scp_cmd = ssh_options.build_scp_cmd("%h", &source, &dest, reverse, preserve, directory);
426
427 ScpWorker {
428 inner: ExecWorker::new(nodes, scp_cmd, fanout, timeout).with_stderr(true),
429 ssh_options,
430 source,
431 dest,
432 reverse,
433 }
434 }
435
436 pub fn source(&self) -> &str {
438 &self.source
439 }
440
441 pub fn dest(&self) -> &str {
443 &self.dest
444 }
445
446 pub fn is_reverse(&self) -> bool {
448 self.reverse
449 }
450}
451
452impl Worker for ScpWorker {
453 fn start(&mut self) -> crate::worker::Result<()> {
454 self.inner.start()
455 }
456
457 fn abort(&mut self, kill: bool) {
458 self.inner.abort(kill);
459 }
460
461 fn state(&self) -> WorkerState {
462 self.inner.state()
463 }
464
465 fn set_handler(&mut self, handler: Box<dyn EventHandler>) {
466 self.inner.set_handler(handler);
467 }
468
469 fn read_fds(&self) -> Vec<RawFd> {
470 self.inner.read_fds()
471 }
472
473 fn write_fds(&self) -> Vec<RawFd> {
474 self.inner.write_fds()
475 }
476
477 fn handle_read(&mut self, fd: RawFd) -> crate::worker::Result<()> {
478 self.inner.handle_read(fd)
479 }
480
481 fn handle_write(&mut self, fd: RawFd) -> crate::worker::Result<()> {
482 self.inner.handle_write(fd)
483 }
484
485 fn is_done(&self) -> bool {
486 self.inner.is_done()
487 }
488
489 fn retcodes(&self) -> &HashMap<String, i32> {
490 self.inner.retcodes()
491 }
492
493 fn num_nodes(&self) -> usize {
494 self.inner.num_nodes()
495 }
496
497 fn take_handler(&mut self) -> Option<Box<dyn EventHandler>> {
498 self.inner.take_handler()
499 }
500}
501
502#[cfg(test)]
503mod tests {
504 use super::*;
505
506 #[test]
507 fn test_ssh_options_default() {
508 let opts = SshOptions::default();
509 assert_eq!(opts.ssh_path, "ssh");
510 assert_eq!(opts.scp_path, "scp");
511 assert!(opts.user.is_none());
512 assert_eq!(opts.connect_timeout, Some(10));
513 assert!(!opts.strict_host_key_checking);
514 assert!(!opts.password_auth);
515 }
516
517 #[test]
518 fn test_ssh_options_build_ssh_cmd() {
519 let opts = SshOptions::default();
520 let cmd = opts.build_ssh_cmd("node1", "uname -a");
521
522 assert!(cmd.starts_with("ssh "));
523 assert!(cmd.contains("-oForwardX11=no"));
524 assert!(cmd.contains("-oStrictHostKeyChecking=no"));
525 assert!(cmd.contains("-oPasswordAuthentication=no"));
526 assert!(cmd.contains("-oConnectTimeout=10"));
527 assert!(cmd.contains("node1"));
528 assert!(cmd.ends_with("uname -a"));
529 }
530
531 #[test]
532 fn test_ssh_options_build_ssh_cmd_with_user() {
533 let opts = SshOptions {
534 user: Some("admin".to_string()),
535 ..SshOptions::default()
536 };
537 let cmd = opts.build_ssh_cmd("node1", "hostname");
538
539 assert!(cmd.contains("-l admin"));
540 assert!(cmd.contains("node1"));
541 }
542
543 #[test]
544 fn test_ssh_options_build_ssh_cmd_custom_options() {
545 let opts = SshOptions {
546 options: vec!["BatchMode=yes".to_string()],
547 ..SshOptions::default()
548 };
549 let cmd = opts.build_ssh_cmd("node1", "ls");
550
551 assert!(cmd.contains("-oBatchMode=yes"));
552 }
553
554 #[test]
555 fn test_ssh_options_build_scp_cmd_forward() {
556 let opts = SshOptions::default();
557 let cmd = opts.build_scp_cmd("node1", "/tmp/file.txt", "/home/user/", false, true, false);
558
559 assert!(cmd.starts_with("scp "));
560 assert!(cmd.contains("-p")); assert!(cmd.contains("/tmp/file.txt"));
562 assert!(cmd.contains("node1:/home/user/"));
563 }
564
565 #[test]
566 fn test_ssh_options_build_scp_cmd_reverse() {
567 let opts = SshOptions::default();
568 let cmd = opts.build_scp_cmd(
569 "node1",
570 "/remote/file.txt",
571 "/local/dir/",
572 true,
573 false,
574 false,
575 );
576
577 assert!(cmd.contains("node1:/remote/file.txt"));
578 assert!(cmd.ends_with("/local/dir/"));
579 }
580
581 #[test]
582 fn test_ssh_options_build_scp_cmd_recursive() {
583 let opts = SshOptions::default();
584 let cmd = opts.build_scp_cmd("node1", "/src/", "/dst/", false, false, true);
585
586 assert!(cmd.contains("-r")); }
588
589 #[test]
590 fn test_ssh_options_build_scp_cmd_with_user() {
591 let opts = SshOptions {
592 user: Some("deploy".to_string()),
593 ..SshOptions::default()
594 };
595 let cmd = opts.build_scp_cmd("node1", "/f", "/d", false, false, false);
596
597 assert!(cmd.contains("deploy@node1:/d"));
598 }
599
600 #[test]
601 fn test_ssh_worker_creation() {
602 let nodes = vec!["node1".to_string(), "node2".to_string()];
603 let worker = SshWorker::with_defaults(nodes, "hostname".to_string(), 64, None);
604
605 assert_eq!(worker.state(), WorkerState::Pending);
606 assert_eq!(worker.num_nodes(), 2);
607 assert_eq!(worker.original_command(), "hostname");
608 }
609
610 #[test]
611 fn test_ssh_worker_custom_options() {
612 let opts = SshOptions {
613 user: Some("root".to_string()),
614 connect_timeout: Some(5),
615 ..SshOptions::default()
616 };
617 let nodes = vec!["node1".to_string()];
618 let worker = SshWorker::new(nodes, "uptime".to_string(), 64, None, opts);
619
620 assert_eq!(worker.ssh_options().user.as_deref(), Some("root"));
621 assert_eq!(worker.ssh_options().connect_timeout, Some(5));
622 }
623
624 #[test]
625 fn test_ssh_worker_with_stderr() {
626 let nodes = vec!["node1".to_string()];
627 let worker = SshWorker::with_defaults(nodes, "ls".to_string(), 64, None).with_stderr(true);
628
629 assert_eq!(worker.state(), WorkerState::Pending);
630 }
631
632 #[test]
633 fn test_scp_worker_creation() {
634 let nodes = vec!["node1".to_string()];
635 let opts = SshOptions::default();
636 let worker = ScpWorker::new(
637 nodes,
638 "/local/file".to_string(),
639 "/remote/dir".to_string(),
640 64,
641 None,
642 opts,
643 false,
644 true,
645 false,
646 );
647
648 assert_eq!(worker.state(), WorkerState::Pending);
649 assert_eq!(worker.source(), "/local/file");
650 assert_eq!(worker.dest(), "/remote/dir");
651 assert!(!worker.is_reverse());
652 }
653
654 #[test]
655 fn test_scp_worker_reverse() {
656 let nodes = vec!["node1".to_string()];
657 let opts = SshOptions::default();
658 let worker = ScpWorker::new(
659 nodes,
660 "/remote/file".to_string(),
661 "/local/dir".to_string(),
662 64,
663 None,
664 opts,
665 true,
666 false,
667 false,
668 );
669
670 assert!(worker.is_reverse());
671 assert_eq!(worker.num_nodes(), 1);
672 }
673
674 #[test]
675 fn test_ssh_options_with_identity_file() {
676 let opts = SshOptions {
677 identity_file: Some("/path/to/key".to_string()),
678 ..SshOptions::default()
679 };
680 let cmd = opts.build_ssh_cmd("node1", "hostname");
681 assert!(cmd.contains("-i /path/to/key"));
682 }
683
684 #[test]
685 fn test_ssh_options_with_config_file() {
686 let opts = SshOptions {
687 config_file: Some("/etc/ssh/config".to_string()),
688 ..SshOptions::default()
689 };
690 let cmd = opts.build_ssh_cmd("node1", "hostname");
691 assert!(cmd.contains("-F /etc/ssh/config"));
692 }
693
694 #[test]
695 fn test_ssh_options_with_control_master() {
696 let opts = SshOptions {
697 control_master: Some(ControlMaster::Auto),
698 control_path: Some("/tmp/ssh-%r@%h:%p".to_string()),
699 control_persist: Some("10m".to_string()),
700 ..SshOptions::default()
701 };
702 let cmd = opts.build_ssh_cmd("node1", "hostname");
703 assert!(cmd.contains("-oControlMaster=auto"));
704 assert!(cmd.contains("-oControlPath=/tmp/ssh-%r@%h:%p"));
705 assert!(cmd.contains("-oControlPersist=10m"));
706 }
707
708 #[test]
709 fn test_ssh_options_with_proxy_jump() {
710 let opts = SshOptions {
711 proxy_jump: Some("bastion.example.com".to_string()),
712 ..SshOptions::default()
713 };
714 let cmd = opts.build_ssh_cmd("node1", "hostname");
715 assert!(cmd.contains("-J bastion.example.com"));
716 }
717
718 #[test]
719 fn test_ssh_options_with_port() {
720 let opts = SshOptions {
721 port: Some(2222),
722 ..SshOptions::default()
723 };
724 let ssh_cmd = opts.build_ssh_cmd("node1", "hostname");
725 assert!(ssh_cmd.contains("-p 2222"));
726
727 let scp_cmd = opts.build_scp_cmd("node1", "/src", "/dst", false, false, false);
728 assert!(scp_cmd.contains("-P 2222")); }
730
731 #[test]
732 fn test_scp_options_with_control_master() {
733 let opts = SshOptions {
734 control_master: Some(ControlMaster::Auto),
735 control_path: Some("/tmp/ssh-%r@%h:%p".to_string()),
736 identity_file: Some("/key".to_string()),
737 ..SshOptions::default()
738 };
739 let cmd = opts.build_scp_cmd("node1", "/src", "/dst", false, false, false);
740 assert!(cmd.contains("-oControlMaster=auto"));
741 assert!(cmd.contains("-oControlPath=/tmp/ssh-%r@%h:%p"));
742 assert!(cmd.contains("-i /key"));
743 }
744
745 #[test]
746 fn test_ssh_worker_abort() {
747 let nodes = vec!["node1".to_string()];
748 let mut worker = SshWorker::with_defaults(nodes, "sleep 100".to_string(), 64, None);
749
750 worker.abort(true);
751 assert_eq!(worker.state(), WorkerState::Aborted);
752 }
753}