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::{AgentMessage, ClientMessage, PROTOCOL_VERSION, write_message};
17use crate::snapshot::Snapshot;
18use crate::source::{DataSource, ProcessSignal, SourceError};
19
20pub const DEFAULT_REMOTE_COMMAND: &str = "rigtop --agent";
22pub const FALLBACK_REMOTE_COMMANDS: [&str; 2] =
27 ["~/.local/bin/rigtop --agent", "~/.cargo/bin/rigtop --agent"];
28const NOT_FOUND_STATUS: i32 = 127;
30
31const RECONNECT_DELAY: Duration = Duration::from_secs(5);
33const CONNECT_TIMEOUT: Duration = Duration::from_secs(20);
36const SIGNAL_TIMEOUT: Duration = Duration::from_secs(10);
38const STDERR_LINES: usize = 20;
40
41#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct RemoteTarget {
44 pub name: String,
46 pub program: String,
48 pub args: Vec<String>,
50 pub fallback_args: Vec<Vec<String>>,
53}
54
55impl RemoteTarget {
56 pub fn ssh(destination: &str, remote_command: &str) -> Self {
59 let options = [
60 "BatchMode=yes",
62 "ConnectTimeout=10",
63 "ServerAliveInterval=15",
65 "ServerAliveCountMax=3",
66 ];
67 let mut args = vec!["-T".to_owned()];
68 for option in options {
69 args.push("-o".to_owned());
70 args.push(option.to_owned());
71 }
72 args.push("--".to_owned());
74 args.push(destination.to_owned());
75 args.push(remote_command.to_owned());
76 let fallback_args = if remote_command == DEFAULT_REMOTE_COMMAND {
79 FALLBACK_REMOTE_COMMANDS
80 .iter()
81 .map(|command| {
82 let mut fallback = args.clone();
83 *fallback.last_mut().expect("command pushed above") = (*command).to_owned();
84 fallback
85 })
86 .collect()
87 } else {
88 Vec::new()
89 };
90 Self {
91 name: destination.to_owned(),
92 program: "ssh".to_owned(),
93 args,
94 fallback_args,
95 }
96 }
97}
98
99enum Incoming {
101 Message(AgentMessage),
102 Closed,
104}
105
106struct Connection {
108 child: Child,
109 stdin: ChildStdin,
110 incoming: Receiver<Incoming>,
111 stderr: Arc<Mutex<VecDeque<String>>>,
113 established: bool,
115}
116
117impl Connection {
118 fn spawn(target: &RemoteTarget, args: &[String]) -> Result<Self, SourceError> {
119 let mut command = Command::new(&target.program);
120 command
121 .args(args)
122 .stdin(Stdio::piped())
123 .stdout(Stdio::piped())
124 .stderr(Stdio::piped());
125 #[cfg(windows)]
126 {
127 use std::os::windows::process::CommandExt;
128 command.creation_flags(0x0800_0000);
130 }
131 let mut child = command.spawn().map_err(|err| {
132 SourceError::Disconnected(format!("cannot start {}: {err}", target.program))
133 })?;
134 let stdin = child.stdin.take().expect("piped stdin");
135 let stdout = child.stdout.take().expect("piped stdout");
136 let stderr_pipe = child.stderr.take().expect("piped stderr");
137
138 let (tx, incoming) = mpsc::channel();
139 thread::Builder::new()
140 .name("rigtop-remote-reader".to_owned())
141 .spawn(move || {
142 for line in BufReader::new(stdout).lines() {
143 let Ok(line) = line else { break };
144 if let Ok(message) = serde_json::from_str::<AgentMessage>(&line)
147 && tx.send(Incoming::Message(message)).is_err()
148 {
149 return;
150 }
151 }
152 let _ = tx.send(Incoming::Closed);
153 })
154 .map_err(SourceError::Io)?;
155
156 let stderr = Arc::new(Mutex::new(VecDeque::new()));
157 let lines = Arc::clone(&stderr);
158 thread::Builder::new()
159 .name("rigtop-remote-stderr".to_owned())
160 .spawn(move || {
161 for line in BufReader::new(stderr_pipe).lines() {
162 let Ok(line) = line else { break };
163 let mut lines = lines.lock().unwrap_or_else(|e| e.into_inner());
164 if lines.len() == STDERR_LINES {
165 lines.pop_front();
166 }
167 lines.push_back(line);
168 }
169 })
170 .map_err(SourceError::Io)?;
171
172 Ok(Self {
173 child,
174 stdin,
175 incoming,
176 stderr,
177 established: false,
178 })
179 }
180
181 fn send(&mut self, message: &ClientMessage) -> Result<(), SourceError> {
182 write_message(&mut self.stdin, message).map_err(SourceError::Io)
183 }
184
185 fn failure(&mut self) -> (String, bool) {
188 thread::sleep(Duration::from_millis(100));
190 let status = self.child.try_wait().ok().flatten();
191 let lines = self.stderr.lock().unwrap_or_else(|e| e.into_inner());
192 let detail = lines
193 .iter()
194 .rev()
195 .find(|l| !l.trim().is_empty())
196 .cloned()
197 .unwrap_or_default();
198 let not_found = status.and_then(|s| s.code()) == Some(NOT_FOUND_STATUS)
200 || detail.contains("command not found")
201 || detail.contains("not recognized");
202 let reason = match (detail.is_empty(), status) {
203 (false, _) => detail,
204 (true, Some(status)) => format!("connection closed ({status})"),
205 (true, None) => "connection closed".to_owned(),
206 };
207 (reason, not_found)
208 }
209}
210
211impl Drop for Connection {
212 fn drop(&mut self) {
213 let _ = self.child.kill();
216 let _ = self.child.wait();
217 }
218}
219
220pub struct RemoteSource {
222 target: RemoteTarget,
223 connection: Option<Connection>,
224 last_attempt: Option<Instant>,
225 interval: Duration,
226 process_limit: Option<usize>,
227 pending: VecDeque<Snapshot>,
229 agent_version: String,
231 last_error: Option<String>,
233 attempt: usize,
236 retry_now: bool,
238}
239
240impl RemoteSource {
241 pub fn new(target: RemoteTarget) -> Self {
243 Self {
244 target,
245 connection: None,
246 last_attempt: None,
247 interval: Duration::from_secs(1),
248 process_limit: None,
249 pending: VecDeque::new(),
250 agent_version: String::new(),
251 last_error: None,
252 attempt: 0,
253 retry_now: false,
254 }
255 }
256
257 pub fn target(&self) -> &RemoteTarget {
259 &self.target
260 }
261
262 fn connection(&mut self) -> Result<&mut Connection, SourceError> {
264 if self.connection.is_none() {
265 if let Some(at) = self.last_attempt {
266 let wait = RECONNECT_DELAY.saturating_sub(at.elapsed());
267 if !wait.is_zero() {
268 let reason = self.last_error.as_deref().unwrap_or("not connected");
269 return Err(SourceError::Disconnected(format!(
270 "{}: {reason} (retrying in {}s)",
271 self.target.name,
272 wait.as_secs() + 1
273 )));
274 }
275 }
276 self.last_attempt = Some(Instant::now());
277 let args = match self.attempt {
278 0 => &self.target.args,
279 n => self
280 .target
281 .fallback_args
282 .get(n - 1)
283 .unwrap_or(&self.target.args),
284 };
285 let mut connection = match Connection::spawn(&self.target, args) {
286 Ok(connection) => connection,
287 Err(err) => {
288 self.last_error = Some(match &err {
289 SourceError::Disconnected(reason) => reason.clone(),
290 other => other.to_string(),
291 });
292 return Err(err);
293 }
294 };
295 let sent = connection
297 .send(&ClientMessage::SetInterval {
298 ms: self.interval.as_millis() as u64,
299 })
300 .and_then(|()| {
301 connection.send(&ClientMessage::SetProcessLimit {
302 limit: self.process_limit,
303 })
304 });
305 self.connection = Some(connection);
306 if sent.is_err() {
307 return Err(self.disconnect(None));
310 }
311 }
312 Ok(self.connection.as_mut().expect("connected above"))
313 }
314
315 fn disconnect(&mut self, reason: Option<String>) -> SourceError {
317 let (mut reason, not_found) = match (reason, self.connection.as_mut()) {
318 (Some(reason), _) => (reason, false),
319 (None, Some(connection)) => connection.failure(),
320 (None, None) => ("connection closed".to_owned(), false),
321 };
322 self.connection = None;
323 if not_found && !self.target.fallback_args.is_empty() {
324 if self.attempt < self.target.fallback_args.len() {
325 self.attempt += 1;
326 self.retry_now = true;
327 self.last_attempt = None;
328 } else {
329 self.attempt = 0;
332 reason = format!(
333 "rigtop is not on the PATH, in ~/.local/bin or in ~/.cargo/bin ({reason})"
334 );
335 }
336 }
337 let error = SourceError::Disconnected(format!("{}: {reason}", self.target.name));
338 self.last_error = Some(reason);
339 error
340 }
341
342 fn next_message(&mut self, timeout: Duration) -> Result<Option<AgentMessage>, SourceError> {
344 let connection = self.connection()?;
345 match connection.incoming.recv_timeout(timeout) {
346 Ok(Incoming::Message(message)) => Ok(Some(message)),
347 Ok(Incoming::Closed) | Err(RecvTimeoutError::Disconnected) => {
348 Err(self.disconnect(None))
349 }
350 Err(RecvTimeoutError::Timeout) => Ok(None),
351 }
352 }
353
354 fn absorb(&mut self, message: AgentMessage) -> Result<(), SourceError> {
357 match message {
358 AgentMessage::Hello { protocol, version } if protocol != PROTOCOL_VERSION => {
359 let reason = format!(
360 "agent speaks protocol {protocol} (rigtop {version}), this client speaks {PROTOCOL_VERSION}; \
361 install the same rigtop version on both sides"
362 );
363 self.last_attempt = Some(Instant::now());
365 Err(self.disconnect(Some(reason)))
366 }
367 AgentMessage::Hello { version, .. } => {
368 self.agent_version = version;
369 Ok(())
370 }
371 AgentMessage::SignalResult { .. } => Ok(()),
372 AgentMessage::Snapshot { mut snapshot } => {
373 if snapshot.host.rigtop_version.is_empty() {
375 snapshot.host.rigtop_version = self.agent_version.clone();
376 }
377 if let Some(connection) = self.connection.as_mut() {
378 connection.established = true;
379 }
380 self.last_error = None;
381 self.pending.push_back(*snapshot);
382 Ok(())
383 }
384 AgentMessage::Error { message } => Err(SourceError::Remote(message)),
385 }
386 }
387}
388
389impl RemoteSource {
390 fn poll_once(&mut self) -> Result<Snapshot, SourceError> {
392 let established = self.connection.as_ref().is_some_and(|c| c.established);
394 let timeout = if established {
395 self.interval * 3 + Duration::from_secs(5)
396 } else {
397 CONNECT_TIMEOUT
398 };
399 let deadline = Instant::now() + timeout;
400 loop {
401 while let Some(message) = self.next_message(Duration::ZERO)? {
403 self.absorb(message)?;
404 }
405 if let Some(newest) = self.pending.pop_back() {
406 self.pending.clear();
407 return Ok(newest);
408 }
409 let remaining = deadline.saturating_duration_since(Instant::now());
410 if remaining.is_zero() {
411 return Err(self.disconnect(Some("no data from the agent (timed out)".to_owned())));
412 }
413 if let Some(message) = self.next_message(remaining)? {
414 self.absorb(message)?;
415 }
416 }
417 }
418}
419
420impl DataSource for RemoteSource {
421 fn poll(&mut self) -> Result<Snapshot, SourceError> {
424 loop {
425 match self.poll_once() {
426 Err(_) if std::mem::take(&mut self.retry_now) => continue,
429 result => return result,
430 }
431 }
432 }
433
434 fn signal_process(&mut self, pid: u32, signal: ProcessSignal) -> Result<(), SourceError> {
435 if self
436 .connection()?
437 .send(&ClientMessage::Signal { pid, signal })
438 .is_err()
439 {
440 return Err(self.disconnect(None));
441 }
442 let deadline = Instant::now() + SIGNAL_TIMEOUT;
443 loop {
444 let remaining = deadline.saturating_duration_since(Instant::now());
445 if remaining.is_zero() {
446 return Err(SourceError::Remote(format!(
447 "no answer to the signal for {pid}"
448 )));
449 }
450 match self.next_message(remaining)? {
451 Some(AgentMessage::SignalResult {
452 pid: answered,
453 error,
454 ..
455 }) if answered == pid => {
456 return match error {
457 None => Ok(()),
458 Some(error) => Err(SourceError::Remote(error)),
459 };
460 }
461 Some(other) => self.absorb(other)?,
462 None => {}
463 }
464 }
465 }
466
467 fn set_interval(&mut self, interval: Duration) {
468 self.interval = interval;
469 if let Some(connection) = self.connection.as_mut() {
470 let _ = connection.send(&ClientMessage::SetInterval {
471 ms: interval.as_millis() as u64,
472 });
473 }
474 }
475
476 fn set_process_limit(&mut self, limit: Option<usize>) {
477 self.process_limit = limit;
478 if let Some(connection) = self.connection.as_mut() {
479 let _ = connection.send(&ClientMessage::SetProcessLimit { limit });
480 }
481 }
482}
483
484#[cfg(test)]
485mod tests {
486 use super::*;
487
488 #[test]
489 fn ssh_target_uses_batch_mode_and_keeps_the_destination_safe() {
490 let target = RemoteTarget::ssh("zefat", DEFAULT_REMOTE_COMMAND);
491 assert_eq!(target.program, "ssh");
492 assert_eq!(target.name, "zefat");
493 assert!(target.args.windows(2).any(|w| w == ["-o", "BatchMode=yes"]));
494 let n = target.args.len();
495 assert_eq!(&target.args[n - 3..], ["--", "zefat", "rigtop --agent"]);
496 let fallbacks: Vec<&str> = target
498 .fallback_args
499 .iter()
500 .map(|args| args[n - 1].as_str())
501 .collect();
502 assert_eq!(fallbacks, FALLBACK_REMOTE_COMMANDS);
503 assert_eq!(&target.fallback_args[0][n - 3..n - 1], ["--", "zefat"]);
504 let custom = RemoteTarget::ssh("zefat", "/opt/rigtop/bin/rigtop --agent");
506 assert!(custom.fallback_args.is_empty());
507 }
508
509 #[cfg(unix)]
511 fn sh_target(script: &str, fallbacks: &[&str]) -> RemoteTarget {
512 let args = |script: &str| vec!["-c".to_owned(), script.to_owned()];
513 RemoteTarget {
514 name: "fake".to_owned(),
515 program: "sh".to_owned(),
516 args: args(script),
517 fallback_args: fallbacks.iter().map(|script| args(script)).collect(),
518 }
519 }
520
521 #[cfg(unix)]
522 #[test]
523 fn command_not_found_tries_the_fallback_at_once() {
524 let missing = "echo 'zsh:1: command not found: rigtop' >&2; exit 127";
526 let mut source = RemoteSource::new(sh_target(missing, &[missing, missing]));
527 let err = source.poll().unwrap_err().to_string();
528 assert!(
529 err.contains("not on the PATH, in ~/.local/bin or in ~/.cargo/bin"),
530 "{err}"
531 );
532 assert_eq!(source.attempt, 0, "the PATH comes first again next time");
533
534 let mut source = RemoteSource::new(sh_target(
536 "echo 'Permission denied (publickey)' >&2; exit 255",
537 &[missing],
538 ));
539 let err = source.poll().unwrap_err().to_string();
540 assert!(err.contains("Permission denied"), "{err}");
541 assert_eq!(source.attempt, 0);
542 }
543
544 #[test]
545 fn missing_program_is_reported_and_retried_later() {
546 let mut source = RemoteSource::new(RemoteTarget {
547 name: "nowhere".to_owned(),
548 program: "/nonexistent/ssh".to_owned(),
549 args: Vec::new(),
550 fallback_args: Vec::new(),
551 });
552 let err = source.poll().unwrap_err().to_string();
553 assert!(err.contains("cannot start"), "{err}");
554 let err = source.poll().unwrap_err().to_string();
556 assert!(
557 err.contains("retrying") && err.contains("cannot start"),
558 "{err}"
559 );
560 }
561
562 #[cfg(unix)]
563 #[test]
564 fn stderr_explains_an_agent_that_exits() {
565 let mut source = RemoteSource::new(RemoteTarget {
567 name: "fake".to_owned(),
568 program: "sh".to_owned(),
569 args: vec![
570 "-c".to_owned(),
571 "echo 'rigtop: command not found' >&2; exit 127".to_owned(),
572 ],
573 fallback_args: Vec::new(),
574 });
575 let err = source.poll().unwrap_err().to_string();
576 assert!(err.contains("fake: rigtop: command not found"), "{err}");
577 }
578
579 #[cfg(unix)]
580 #[test]
581 fn incompatible_protocol_is_rejected() {
582 let mut source = RemoteSource::new(RemoteTarget {
583 name: "old".to_owned(),
584 program: "sh".to_owned(),
585 args: vec![
586 "-c".to_owned(),
587 r#"echo '{"type":"hello","protocol":999,"version":"9.9.9"}'; sleep 5"#.to_owned(),
588 ],
589 fallback_args: Vec::new(),
590 });
591 let err = source.poll().unwrap_err().to_string();
592 assert!(err.contains("protocol 999"), "{err}");
593 }
594
595 #[cfg(unix)]
596 #[test]
597 fn old_agents_get_their_version_from_the_greeting() {
598 let mut source = RemoteSource::new(RemoteTarget {
600 name: "old".to_owned(),
601 program: "sh".to_owned(),
602 args: vec![
603 "-c".to_owned(),
604 r#"echo '{"type":"hello","protocol":1,"version":"0.8.2"}'
605 echo '{"type":"snapshot","snapshot":{"host":{"hostname":"nas"}}}'
606 sleep 5"#
607 .to_owned(),
608 ],
609 fallback_args: Vec::new(),
610 });
611 let snapshot = source.poll().unwrap();
612 assert_eq!(snapshot.host.hostname, "nas");
613 assert_eq!(snapshot.host.rigtop_version, "0.8.2");
614 assert_eq!(
615 crate::view::version_note(&snapshot).as_deref(),
616 Some("agent 0.8.2")
617 );
618 }
619}