1use std::collections::VecDeque;
9use std::io::{BufRead, BufReader};
10use std::process::{Child, ChildStdin, Command, Stdio};
11use std::sync::mpsc::{self, Receiver, RecvTimeoutError};
12use std::sync::{Arc, Mutex};
13use std::thread;
14use std::time::{Duration, Instant};
15
16use crate::agent::{
17 AgentMessage, ClientMessage, FEATURE_PROCESS_CONTROL, FEATURE_WATCH_PROCESSES,
18 PROTOCOL_VERSION, write_message,
19};
20use crate::snapshot::Snapshot;
21use crate::source::{DataSource, ProcessSignal, SourceError};
22
23pub const DEFAULT_REMOTE_COMMAND: &str = "rigtop --agent";
25pub const FALLBACK_REMOTE_COMMANDS: [&str; 2] =
30 ["~/.local/bin/rigtop --agent", "~/.cargo/bin/rigtop --agent"];
31const NOT_FOUND_STATUS: i32 = 127;
33
34const RECONNECT_DELAY: Duration = Duration::from_secs(5);
36const CONNECT_TIMEOUT: Duration = Duration::from_secs(20);
39const SIGNAL_TIMEOUT: Duration = Duration::from_secs(10);
41const STDERR_LINES: usize = 20;
43
44#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct RemoteTarget {
47 pub name: String,
49 pub program: String,
51 pub args: Vec<String>,
53 pub fallback_args: Vec<Vec<String>>,
56}
57
58impl RemoteTarget {
59 pub fn ssh(destination: &str, remote_command: &str) -> Self {
62 let options = [
63 "BatchMode=yes",
65 "ConnectTimeout=10",
66 "ServerAliveInterval=15",
68 "ServerAliveCountMax=3",
69 ];
70 let mut args = vec!["-T".to_owned()];
71 for option in options {
72 args.push("-o".to_owned());
73 args.push(option.to_owned());
74 }
75 args.push("--".to_owned());
77 args.push(destination.to_owned());
78 args.push(remote_command.to_owned());
79 let fallback_args = if remote_command == DEFAULT_REMOTE_COMMAND {
82 FALLBACK_REMOTE_COMMANDS
83 .iter()
84 .map(|command| {
85 let mut fallback = args.clone();
86 *fallback.last_mut().expect("command pushed above") = (*command).to_owned();
87 fallback
88 })
89 .collect()
90 } else {
91 Vec::new()
92 };
93 Self {
94 name: destination.to_owned(),
95 program: "ssh".to_owned(),
96 args,
97 fallback_args,
98 }
99 }
100}
101
102enum Incoming {
104 Message(AgentMessage),
105 Closed,
107}
108
109struct Connection {
111 child: Child,
112 stdin: ChildStdin,
113 incoming: Receiver<Incoming>,
114 stderr: Arc<Mutex<VecDeque<String>>>,
116 established: bool,
118}
119
120impl Connection {
121 fn spawn(target: &RemoteTarget, args: &[String]) -> Result<Self, SourceError> {
122 let mut command = Command::new(&target.program);
123 command
124 .args(args)
125 .stdin(Stdio::piped())
126 .stdout(Stdio::piped())
127 .stderr(Stdio::piped());
128 #[cfg(windows)]
129 {
130 use std::os::windows::process::CommandExt;
131 command.creation_flags(0x0800_0000);
133 }
134 let mut child = command.spawn().map_err(|err| {
135 SourceError::Disconnected(format!("cannot start {}: {err}", target.program))
136 })?;
137 let stdin = child.stdin.take().expect("piped stdin");
138 let stdout = child.stdout.take().expect("piped stdout");
139 let stderr_pipe = child.stderr.take().expect("piped stderr");
140
141 let (tx, incoming) = mpsc::channel();
142 thread::Builder::new()
143 .name("rigtop-remote-reader".to_owned())
144 .spawn(move || {
145 for line in BufReader::new(stdout).lines() {
146 let Ok(line) = line else { break };
147 if let Ok(message) = serde_json::from_str::<AgentMessage>(&line)
150 && tx.send(Incoming::Message(message)).is_err()
151 {
152 return;
153 }
154 }
155 let _ = tx.send(Incoming::Closed);
156 })
157 .map_err(SourceError::Io)?;
158
159 let stderr = Arc::new(Mutex::new(VecDeque::new()));
160 let lines = Arc::clone(&stderr);
161 thread::Builder::new()
162 .name("rigtop-remote-stderr".to_owned())
163 .spawn(move || {
164 for line in BufReader::new(stderr_pipe).lines() {
165 let Ok(line) = line else { break };
166 let mut lines = lines.lock().unwrap_or_else(|e| e.into_inner());
167 if lines.len() == STDERR_LINES {
168 lines.pop_front();
169 }
170 lines.push_back(line);
171 }
172 })
173 .map_err(SourceError::Io)?;
174
175 Ok(Self {
176 child,
177 stdin,
178 incoming,
179 stderr,
180 established: false,
181 })
182 }
183
184 fn send(&mut self, message: &ClientMessage) -> Result<(), SourceError> {
185 write_message(&mut self.stdin, message).map_err(SourceError::Io)
186 }
187
188 fn failure(&mut self) -> (String, bool) {
191 thread::sleep(Duration::from_millis(100));
193 let status = self.child.try_wait().ok().flatten();
194 let lines = self.stderr.lock().unwrap_or_else(|e| e.into_inner());
195 let detail = lines
196 .iter()
197 .rev()
198 .find(|l| !l.trim().is_empty())
199 .cloned()
200 .unwrap_or_default();
201 let not_found = status.and_then(|s| s.code()) == Some(NOT_FOUND_STATUS)
203 || detail.contains("command not found")
204 || detail.contains("not recognized");
205 let reason = match (detail.is_empty(), status) {
206 (false, _) => detail,
207 (true, Some(status)) => format!("connection closed ({status})"),
208 (true, None) => "connection closed".to_owned(),
209 };
210 (reason, not_found)
211 }
212}
213
214impl Drop for Connection {
215 fn drop(&mut self) {
216 let _ = self.child.kill();
219 let _ = self.child.wait();
220 }
221}
222
223pub struct RemoteSource {
225 target: RemoteTarget,
226 connection: Option<Connection>,
227 last_attempt: Option<Instant>,
228 interval: Duration,
229 process_limit: Option<usize>,
230 watched_pids: Vec<u32>,
232 watched_names: Vec<String>,
234 agent_watches: bool,
236 agent_controls: bool,
238 pending: VecDeque<Snapshot>,
240 agent_version: String,
242 last_error: Option<String>,
244 attempt: usize,
247 retry_now: bool,
249}
250
251impl RemoteSource {
252 pub fn new(target: RemoteTarget) -> Self {
254 Self {
255 target,
256 connection: None,
257 last_attempt: None,
258 interval: Duration::from_secs(1),
259 process_limit: None,
260 watched_pids: Vec::new(),
261 watched_names: Vec::new(),
262 agent_watches: false,
263 agent_controls: false,
264 pending: VecDeque::new(),
265 agent_version: String::new(),
266 last_error: None,
267 attempt: 0,
268 retry_now: false,
269 }
270 }
271
272 pub fn target(&self) -> &RemoteTarget {
274 &self.target
275 }
276
277 fn connection(&mut self) -> Result<&mut Connection, SourceError> {
279 if self.connection.is_none() {
280 if let Some(at) = self.last_attempt {
281 let wait = RECONNECT_DELAY.saturating_sub(at.elapsed());
282 if !wait.is_zero() {
283 let reason = self.last_error.as_deref().unwrap_or("not connected");
284 return Err(SourceError::Disconnected(format!(
285 "{}: {reason} (retrying in {}s)",
286 self.target.name,
287 wait.as_secs() + 1
288 )));
289 }
290 }
291 self.last_attempt = Some(Instant::now());
292 let args = match self.attempt {
293 0 => &self.target.args,
294 n => self
295 .target
296 .fallback_args
297 .get(n - 1)
298 .unwrap_or(&self.target.args),
299 };
300 let mut connection = match Connection::spawn(&self.target, args) {
301 Ok(connection) => connection,
302 Err(err) => {
303 self.last_error = Some(match &err {
304 SourceError::Disconnected(reason) => reason.clone(),
305 other => other.to_string(),
306 });
307 return Err(err);
308 }
309 };
310 self.agent_watches = false;
312 let sent = connection
314 .send(&ClientMessage::SetInterval {
315 ms: self.interval.as_millis() as u64,
316 })
317 .and_then(|()| {
318 connection.send(&ClientMessage::SetProcessLimit {
319 limit: self.process_limit,
320 })
321 });
322 self.connection = Some(connection);
323 if sent.is_err() {
324 return Err(self.disconnect(None));
327 }
328 }
329 Ok(self.connection.as_mut().expect("connected above"))
330 }
331
332 fn disconnect(&mut self, reason: Option<String>) -> SourceError {
334 let (mut reason, not_found) = match (reason, self.connection.as_mut()) {
335 (Some(reason), _) => (reason, false),
336 (None, Some(connection)) => connection.failure(),
337 (None, None) => ("connection closed".to_owned(), false),
338 };
339 self.connection = None;
340 if not_found && !self.target.fallback_args.is_empty() {
341 if self.attempt < self.target.fallback_args.len() {
342 self.attempt += 1;
343 self.retry_now = true;
344 self.last_attempt = None;
345 } else {
346 self.attempt = 0;
349 reason = format!(
350 "rigtop is not on the PATH, in ~/.local/bin or in ~/.cargo/bin ({reason})"
351 );
352 }
353 }
354 let error = SourceError::Disconnected(format!("{}: {reason}", self.target.name));
355 self.last_error = Some(reason);
356 error
357 }
358
359 fn next_message(&mut self, timeout: Duration) -> Result<Option<AgentMessage>, SourceError> {
361 let connection = self.connection()?;
362 match connection.incoming.recv_timeout(timeout) {
363 Ok(Incoming::Message(message)) => Ok(Some(message)),
364 Ok(Incoming::Closed) | Err(RecvTimeoutError::Disconnected) => {
365 Err(self.disconnect(None))
366 }
367 Err(RecvTimeoutError::Timeout) => Ok(None),
368 }
369 }
370
371 fn send_watched(&mut self) {
375 if let Some(connection) = self.connection.as_mut() {
376 let _ = connection.send(&ClientMessage::WatchProcesses {
377 pids: self.watched_pids.clone(),
378 names: self.watched_names.clone(),
380 });
381 }
382 }
383
384 fn absorb(&mut self, message: AgentMessage) -> Result<(), SourceError> {
385 match message {
386 AgentMessage::Hello {
387 protocol, version, ..
388 } if protocol != PROTOCOL_VERSION => {
389 let reason = format!(
390 "agent speaks protocol {protocol} (rigtop {version}), this client speaks {PROTOCOL_VERSION}; \
391 install the same rigtop version on both sides"
392 );
393 self.last_attempt = Some(Instant::now());
395 Err(self.disconnect(Some(reason)))
396 }
397 AgentMessage::Hello {
398 version, features, ..
399 } => {
400 self.agent_version = version;
401 self.agent_watches = features.iter().any(|f| f == FEATURE_WATCH_PROCESSES);
402 self.agent_controls = features.iter().any(|f| f == FEATURE_PROCESS_CONTROL);
403 if self.agent_watches
404 && !(self.watched_pids.is_empty() && self.watched_names.is_empty())
405 {
406 self.send_watched();
407 }
408 Ok(())
409 }
410 AgentMessage::SignalResult { .. } => Ok(()),
411 AgentMessage::Snapshot { mut snapshot } => {
412 if snapshot.host.rigtop_version.is_empty() {
414 snapshot.host.rigtop_version = self.agent_version.clone();
415 }
416 if let Some(connection) = self.connection.as_mut() {
417 connection.established = true;
418 }
419 self.last_error = None;
420 self.pending.push_back(*snapshot);
421 Ok(())
422 }
423 AgentMessage::Error { message } => Err(SourceError::Remote(message)),
424 }
425 }
426}
427
428impl RemoteSource {
429 fn poll_once(&mut self) -> Result<Snapshot, SourceError> {
431 let established = self.connection.as_ref().is_some_and(|c| c.established);
433 let timeout = if established {
434 self.interval * 3 + Duration::from_secs(5)
435 } else {
436 CONNECT_TIMEOUT
437 };
438 let deadline = Instant::now() + timeout;
439 loop {
440 while let Some(message) = self.next_message(Duration::ZERO)? {
442 self.absorb(message)?;
443 }
444 if let Some(newest) = self.pending.pop_back() {
445 self.pending.clear();
446 return Ok(newest);
447 }
448 let remaining = deadline.saturating_duration_since(Instant::now());
449 if remaining.is_zero() {
450 return Err(self.disconnect(Some("no data from the agent (timed out)".to_owned())));
451 }
452 if let Some(message) = self.next_message(remaining)? {
453 self.absorb(message)?;
454 }
455 }
456 }
457}
458
459impl DataSource for RemoteSource {
460 fn poll(&mut self) -> Result<Snapshot, SourceError> {
463 loop {
464 match self.poll_once() {
465 Err(_) if std::mem::take(&mut self.retry_now) => continue,
468 result => return result,
469 }
470 }
471 }
472
473 fn signal_process(&mut self, pid: u32, signal: ProcessSignal) -> Result<(), SourceError> {
474 if signal.is_control() {
475 self.connection()?;
477 if !self.agent_controls {
478 return Err(SourceError::Remote(format!(
479 "rigtop {} on the host cannot do this; it needs 0.22 or later",
480 if self.agent_version.is_empty() {
481 "(unknown)"
482 } else {
483 &self.agent_version
484 }
485 )));
486 }
487 }
488 if self
489 .connection()?
490 .send(&ClientMessage::Signal { pid, signal })
491 .is_err()
492 {
493 return Err(self.disconnect(None));
494 }
495 let deadline = Instant::now() + SIGNAL_TIMEOUT;
496 loop {
497 let remaining = deadline.saturating_duration_since(Instant::now());
498 if remaining.is_zero() {
499 return Err(SourceError::Remote(format!(
500 "no answer to the signal for {pid}"
501 )));
502 }
503 match self.next_message(remaining)? {
504 Some(AgentMessage::SignalResult {
505 pid: answered,
506 error,
507 ..
508 }) if answered == pid => {
509 return match error {
510 None => Ok(()),
511 Some(error) => Err(SourceError::Remote(error)),
512 };
513 }
514 Some(other) => self.absorb(other)?,
515 None => {}
516 }
517 }
518 }
519
520 fn set_interval(&mut self, interval: Duration) {
521 self.interval = interval;
522 if let Some(connection) = self.connection.as_mut() {
523 let _ = connection.send(&ClientMessage::SetInterval {
524 ms: interval.as_millis() as u64,
525 });
526 }
527 }
528
529 fn set_process_limit(&mut self, limit: Option<usize>) {
530 self.process_limit = limit;
531 if let Some(connection) = self.connection.as_mut() {
532 let _ = connection.send(&ClientMessage::SetProcessLimit { limit });
533 }
534 }
535
536 fn watch_processes(&mut self, pids: Vec<u32>) {
537 self.watched_pids = pids;
538 if self.agent_watches {
541 self.send_watched();
542 }
543 }
544
545 fn watch_process_names(&mut self, names: Vec<String>) {
546 self.watched_names = names;
547 if self.agent_watches {
548 self.send_watched();
549 }
550 }
551}
552
553#[cfg(test)]
554mod tests {
555 use super::*;
556
557 #[test]
558 fn ssh_target_uses_batch_mode_and_keeps_the_destination_safe() {
559 let target = RemoteTarget::ssh("zefat", DEFAULT_REMOTE_COMMAND);
560 assert_eq!(target.program, "ssh");
561 assert_eq!(target.name, "zefat");
562 assert!(target.args.windows(2).any(|w| w == ["-o", "BatchMode=yes"]));
563 let n = target.args.len();
564 assert_eq!(&target.args[n - 3..], ["--", "zefat", "rigtop --agent"]);
565 let fallbacks: Vec<&str> = target
567 .fallback_args
568 .iter()
569 .map(|args| args[n - 1].as_str())
570 .collect();
571 assert_eq!(fallbacks, FALLBACK_REMOTE_COMMANDS);
572 assert_eq!(&target.fallback_args[0][n - 3..n - 1], ["--", "zefat"]);
573 let custom = RemoteTarget::ssh("zefat", "/opt/rigtop/bin/rigtop --agent");
575 assert!(custom.fallback_args.is_empty());
576 }
577
578 #[cfg(unix)]
580 fn sh_target(script: &str, fallbacks: &[&str]) -> RemoteTarget {
581 let args = |script: &str| vec!["-c".to_owned(), script.to_owned()];
582 RemoteTarget {
583 name: "fake".to_owned(),
584 program: "sh".to_owned(),
585 args: args(script),
586 fallback_args: fallbacks.iter().map(|script| args(script)).collect(),
587 }
588 }
589
590 #[cfg(unix)]
591 #[test]
592 fn command_not_found_tries_the_fallback_at_once() {
593 let missing = "echo 'zsh:1: command not found: rigtop' >&2; exit 127";
595 let mut source = RemoteSource::new(sh_target(missing, &[missing, missing]));
596 let err = source.poll().unwrap_err().to_string();
597 assert!(
598 err.contains("not on the PATH, in ~/.local/bin or in ~/.cargo/bin"),
599 "{err}"
600 );
601 assert_eq!(source.attempt, 0, "the PATH comes first again next time");
602
603 let mut source = RemoteSource::new(sh_target(
605 "echo 'Permission denied (publickey)' >&2; exit 255",
606 &[missing],
607 ));
608 let err = source.poll().unwrap_err().to_string();
609 assert!(err.contains("Permission denied"), "{err}");
610 assert_eq!(source.attempt, 0);
611 }
612
613 #[test]
614 fn missing_program_is_reported_and_retried_later() {
615 let mut source = RemoteSource::new(RemoteTarget {
616 name: "nowhere".to_owned(),
617 program: "/nonexistent/ssh".to_owned(),
618 args: Vec::new(),
619 fallback_args: Vec::new(),
620 });
621 let err = source.poll().unwrap_err().to_string();
622 assert!(err.contains("cannot start"), "{err}");
623 let err = source.poll().unwrap_err().to_string();
625 assert!(
626 err.contains("retrying") && err.contains("cannot start"),
627 "{err}"
628 );
629 }
630
631 #[cfg(unix)]
632 #[test]
633 fn stderr_explains_an_agent_that_exits() {
634 let mut source = RemoteSource::new(RemoteTarget {
636 name: "fake".to_owned(),
637 program: "sh".to_owned(),
638 args: vec![
639 "-c".to_owned(),
640 "echo 'rigtop: command not found' >&2; exit 127".to_owned(),
641 ],
642 fallback_args: Vec::new(),
643 });
644 let err = source.poll().unwrap_err().to_string();
645 assert!(err.contains("fake: rigtop: command not found"), "{err}");
646 }
647
648 #[cfg(unix)]
649 #[test]
650 fn incompatible_protocol_is_rejected() {
651 let mut source = RemoteSource::new(RemoteTarget {
652 name: "old".to_owned(),
653 program: "sh".to_owned(),
654 args: vec![
655 "-c".to_owned(),
656 r#"echo '{"type":"hello","protocol":999,"version":"9.9.9"}'; sleep 5"#.to_owned(),
657 ],
658 fallback_args: Vec::new(),
659 });
660 let err = source.poll().unwrap_err().to_string();
661 assert!(err.contains("protocol 999"), "{err}");
662 }
663
664 #[cfg(unix)]
665 #[test]
666 fn old_agents_get_their_version_from_the_greeting() {
667 let mut source = RemoteSource::new(RemoteTarget {
669 name: "old".to_owned(),
670 program: "sh".to_owned(),
671 args: vec![
672 "-c".to_owned(),
673 r#"echo '{"type":"hello","protocol":1,"version":"0.8.2"}'
674 echo '{"type":"snapshot","snapshot":{"host":{"hostname":"nas"}}}'
675 sleep 5"#
676 .to_owned(),
677 ],
678 fallback_args: Vec::new(),
679 });
680 let snapshot = source.poll().unwrap();
681 assert_eq!(snapshot.host.hostname, "nas");
682 assert_eq!(snapshot.host.rigtop_version, "0.8.2");
683 assert_eq!(
684 crate::view::version_note(&snapshot).as_deref(),
685 Some("agent 0.8.2")
686 );
687 }
688}