Skip to main content

rigtop_core/
remote.rs

1//! A [`DataSource`] for a remote host, reached by running `rigtop --agent`
2//! through the system's `ssh` (OpenSSH; `ssh.exe` on Windows).
3//!
4//! Using the system client means `~/.ssh/config`, keys, agents and
5//! `ProxyJump` all work as on the command line. Authentication must not
6//! need a prompt (`BatchMode`), since there is no terminal to ask on.
7
8use 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
20/// Command run on the remote host by default.
21pub const DEFAULT_REMOTE_COMMAND: &str = "rigtop --agent";
22/// Tried when the default command is not found: `cargo install` puts rigtop
23/// in `~/.cargo/bin`, which non-interactive SSH sessions often lack on
24/// their `PATH`.
25pub const FALLBACK_REMOTE_COMMAND: &str = "~/.cargo/bin/rigtop --agent";
26/// Exit status of POSIX shells for "command not found".
27const NOT_FOUND_STATUS: i32 = 127;
28
29/// Minimum time between connection attempts after a failure.
30const RECONNECT_DELAY: Duration = Duration::from_secs(5);
31/// How long to wait for the first snapshot of a new connection (SSH
32/// handshake plus the agent's warm-up sample).
33const CONNECT_TIMEOUT: Duration = Duration::from_secs(20);
34/// How long to wait for the agent to answer a signal request.
35const SIGNAL_TIMEOUT: Duration = Duration::from_secs(10);
36/// stderr lines kept to explain failures.
37const STDERR_LINES: usize = 20;
38
39/// How to start the agent: a program and its arguments.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct RemoteTarget {
42    /// Short name for messages (e.g. the SSH destination).
43    pub name: String,
44    /// Program to run (normally `ssh`).
45    pub program: String,
46    /// Its arguments.
47    pub args: Vec<String>,
48    /// Arguments to use instead when the remote command is not found (for
49    /// the default command: [`FALLBACK_REMOTE_COMMAND`]).
50    pub fallback_args: Option<Vec<String>>,
51}
52
53impl RemoteTarget {
54    /// Runs `remote_command` on `destination` (anything `ssh` accepts:
55    /// a `~/.ssh/config` host, `user@host`, ...).
56    pub fn ssh(destination: &str, remote_command: &str) -> Self {
57        let options = [
58            // No password or host key prompts: fail instead of hanging.
59            "BatchMode=yes",
60            "ConnectTimeout=10",
61            // Notice dead connections within ~45 s.
62            "ServerAliveInterval=15",
63            "ServerAliveCountMax=3",
64        ];
65        let mut args = vec!["-T".to_owned()];
66        for option in options {
67            args.push("-o".to_owned());
68            args.push(option.to_owned());
69        }
70        // `--` keeps a destination starting with "-" from being an option.
71        args.push("--".to_owned());
72        args.push(destination.to_owned());
73        args.push(remote_command.to_owned());
74        // Only the default command falls back; one set by the user is
75        // taken as it is.
76        let fallback_args = (remote_command == DEFAULT_REMOTE_COMMAND).then(|| {
77            let mut fallback = args.clone();
78            *fallback.last_mut().expect("command pushed above") =
79                FALLBACK_REMOTE_COMMAND.to_owned();
80            fallback
81        });
82        Self {
83            name: destination.to_owned(),
84            program: "ssh".to_owned(),
85            args,
86            fallback_args,
87        }
88    }
89}
90
91/// What the reader thread passes on.
92enum Incoming {
93    Message(AgentMessage),
94    /// stdout closed: the agent or ssh exited.
95    Closed,
96}
97
98/// One running agent process.
99struct Connection {
100    child: Child,
101    stdin: ChildStdin,
102    incoming: Receiver<Incoming>,
103    /// Last lines of the process's stderr (ssh errors, "command not found").
104    stderr: Arc<Mutex<VecDeque<String>>>,
105    /// Whether a snapshot has arrived on this connection yet.
106    established: bool,
107}
108
109impl Connection {
110    fn spawn(target: &RemoteTarget, args: &[String]) -> Result<Self, SourceError> {
111        let mut command = Command::new(&target.program);
112        command
113            .args(args)
114            .stdin(Stdio::piped())
115            .stdout(Stdio::piped())
116            .stderr(Stdio::piped());
117        #[cfg(windows)]
118        {
119            use std::os::windows::process::CommandExt;
120            // CREATE_NO_WINDOW: a GUI must not flash a console for ssh.exe.
121            command.creation_flags(0x0800_0000);
122        }
123        let mut child = command.spawn().map_err(|err| {
124            SourceError::Disconnected(format!("cannot start {}: {err}", target.program))
125        })?;
126        let stdin = child.stdin.take().expect("piped stdin");
127        let stdout = child.stdout.take().expect("piped stdout");
128        let stderr_pipe = child.stderr.take().expect("piped stderr");
129
130        let (tx, incoming) = mpsc::channel();
131        thread::Builder::new()
132            .name("rigtop-remote-reader".to_owned())
133            .spawn(move || {
134                for line in BufReader::new(stdout).lines() {
135                    let Ok(line) = line else { break };
136                    // Lines that are not protocol messages (e.g. a login
137                    // banner printed by a shell profile) are skipped.
138                    if let Ok(message) = serde_json::from_str::<AgentMessage>(&line)
139                        && tx.send(Incoming::Message(message)).is_err()
140                    {
141                        return;
142                    }
143                }
144                let _ = tx.send(Incoming::Closed);
145            })
146            .map_err(SourceError::Io)?;
147
148        let stderr = Arc::new(Mutex::new(VecDeque::new()));
149        let lines = Arc::clone(&stderr);
150        thread::Builder::new()
151            .name("rigtop-remote-stderr".to_owned())
152            .spawn(move || {
153                for line in BufReader::new(stderr_pipe).lines() {
154                    let Ok(line) = line else { break };
155                    let mut lines = lines.lock().unwrap_or_else(|e| e.into_inner());
156                    if lines.len() == STDERR_LINES {
157                        lines.pop_front();
158                    }
159                    lines.push_back(line);
160                }
161            })
162            .map_err(SourceError::Io)?;
163
164        Ok(Self {
165            child,
166            stdin,
167            incoming,
168            stderr,
169            established: false,
170        })
171    }
172
173    fn send(&mut self, message: &ClientMessage) -> Result<(), SourceError> {
174        write_message(&mut self.stdin, message).map_err(SourceError::Io)
175    }
176
177    /// Explanation for a lost connection, from the process's stderr, and
178    /// whether the remote command was not found.
179    fn failure(&mut self) -> (String, bool) {
180        // Give the stderr thread a moment to collect the final lines.
181        thread::sleep(Duration::from_millis(100));
182        let status = self.child.try_wait().ok().flatten();
183        let lines = self.stderr.lock().unwrap_or_else(|e| e.into_inner());
184        let detail = lines
185            .iter()
186            .rev()
187            .find(|l| !l.trim().is_empty())
188            .cloned()
189            .unwrap_or_default();
190        // ssh passes the remote exit status on; cmd.exe says "not recognized".
191        let not_found = status.and_then(|s| s.code()) == Some(NOT_FOUND_STATUS)
192            || detail.contains("command not found")
193            || detail.contains("not recognized");
194        let reason = match (detail.is_empty(), status) {
195            (false, _) => detail,
196            (true, Some(status)) => format!("connection closed ({status})"),
197            (true, None) => "connection closed".to_owned(),
198        };
199        (reason, not_found)
200    }
201}
202
203impl Drop for Connection {
204    fn drop(&mut self) {
205        // Closing stdin makes the agent exit; killing ssh ends the session
206        // right away instead of waiting for it.
207        let _ = self.child.kill();
208        let _ = self.child.wait();
209    }
210}
211
212/// Snapshots from `rigtop --agent` on another machine.
213pub struct RemoteSource {
214    target: RemoteTarget,
215    connection: Option<Connection>,
216    last_attempt: Option<Instant>,
217    interval: Duration,
218    process_limit: Option<usize>,
219    /// Snapshots received while waiting for something else (signal results).
220    pending: VecDeque<Snapshot>,
221    /// rigtop version from the agent's greeting.
222    agent_version: String,
223    /// Why the last connection failed, repeated while waiting to retry.
224    last_error: Option<String>,
225    /// Whether to start the agent with the fallback command.
226    use_fallback: bool,
227    /// The command was not found and the fallback should be tried at once.
228    retry_now: bool,
229}
230
231impl RemoteSource {
232    /// Creates a source; the connection is made on the first poll.
233    pub fn new(target: RemoteTarget) -> Self {
234        Self {
235            target,
236            connection: None,
237            last_attempt: None,
238            interval: Duration::from_secs(1),
239            process_limit: None,
240            pending: VecDeque::new(),
241            agent_version: String::new(),
242            last_error: None,
243            use_fallback: false,
244            retry_now: false,
245        }
246    }
247
248    /// The target this source connects to.
249    pub fn target(&self) -> &RemoteTarget {
250        &self.target
251    }
252
253    /// Returns the connection, (re)connecting if needed and allowed.
254    fn connection(&mut self) -> Result<&mut Connection, SourceError> {
255        if self.connection.is_none() {
256            if let Some(at) = self.last_attempt {
257                let wait = RECONNECT_DELAY.saturating_sub(at.elapsed());
258                if !wait.is_zero() {
259                    let reason = self.last_error.as_deref().unwrap_or("not connected");
260                    return Err(SourceError::Disconnected(format!(
261                        "{}: {reason} (retrying in {}s)",
262                        self.target.name,
263                        wait.as_secs() + 1
264                    )));
265                }
266            }
267            self.last_attempt = Some(Instant::now());
268            let args = match (&self.target.fallback_args, self.use_fallback) {
269                (Some(fallback), true) => fallback,
270                _ => &self.target.args,
271            };
272            let mut connection = match Connection::spawn(&self.target, args) {
273                Ok(connection) => connection,
274                Err(err) => {
275                    self.last_error = Some(match &err {
276                        SourceError::Disconnected(reason) => reason.clone(),
277                        other => other.to_string(),
278                    });
279                    return Err(err);
280                }
281            };
282            // Agents start with their own default; send our settings.
283            let sent = connection
284                .send(&ClientMessage::SetInterval {
285                    ms: self.interval.as_millis() as u64,
286                })
287                .and_then(|()| {
288                    connection.send(&ClientMessage::SetProcessLimit {
289                        limit: self.process_limit,
290                    })
291                });
292            self.connection = Some(connection);
293            if sent.is_err() {
294                // ssh already exited (bad host, auth failure): its stderr
295                // says why, which beats "broken pipe".
296                return Err(self.disconnect(None));
297            }
298        }
299        Ok(self.connection.as_mut().expect("connected above"))
300    }
301
302    /// Drops the connection and returns an error explaining why.
303    fn disconnect(&mut self, reason: Option<String>) -> SourceError {
304        let (mut reason, not_found) = match (reason, self.connection.as_mut()) {
305            (Some(reason), _) => (reason, false),
306            (None, Some(connection)) => connection.failure(),
307            (None, None) => ("connection closed".to_owned(), false),
308        };
309        self.connection = None;
310        if not_found && self.target.fallback_args.is_some() {
311            if self.use_fallback {
312                // Neither worked; start over with the PATH next time, in
313                // case rigtop gets installed there.
314                self.use_fallback = false;
315                reason = format!("rigtop is neither on the PATH nor in ~/.cargo/bin ({reason})");
316            } else {
317                self.use_fallback = true;
318                self.retry_now = true;
319                self.last_attempt = None;
320            }
321        }
322        let error = SourceError::Disconnected(format!("{}: {reason}", self.target.name));
323        self.last_error = Some(reason);
324        error
325    }
326
327    /// Waits up to `timeout` for the next message.
328    fn next_message(&mut self, timeout: Duration) -> Result<Option<AgentMessage>, SourceError> {
329        let connection = self.connection()?;
330        match connection.incoming.recv_timeout(timeout) {
331            Ok(Incoming::Message(message)) => Ok(Some(message)),
332            Ok(Incoming::Closed) | Err(RecvTimeoutError::Disconnected) => {
333                Err(self.disconnect(None))
334            }
335            Err(RecvTimeoutError::Timeout) => Ok(None),
336        }
337    }
338
339    /// Handles messages that are not what the caller waits for. Returns an
340    /// error for fatal ones (protocol mismatch).
341    fn absorb(&mut self, message: AgentMessage) -> Result<(), SourceError> {
342        match message {
343            AgentMessage::Hello { protocol, version } if protocol != PROTOCOL_VERSION => {
344                let reason = format!(
345                    "agent speaks protocol {protocol} (rigtop {version}), this client speaks {PROTOCOL_VERSION}; \
346                     install the same rigtop version on both sides"
347                );
348                // Do not retry quickly against an incompatible agent.
349                self.last_attempt = Some(Instant::now());
350                Err(self.disconnect(Some(reason)))
351            }
352            AgentMessage::Hello { version, .. } => {
353                self.agent_version = version;
354                Ok(())
355            }
356            AgentMessage::SignalResult { .. } => Ok(()),
357            AgentMessage::Snapshot { mut snapshot } => {
358                // Agents before 0.9.1 do not put their version in snapshots.
359                if snapshot.host.rigtop_version.is_empty() {
360                    snapshot.host.rigtop_version = self.agent_version.clone();
361                }
362                if let Some(connection) = self.connection.as_mut() {
363                    connection.established = true;
364                }
365                self.last_error = None;
366                self.pending.push_back(*snapshot);
367                Ok(())
368            }
369            AgentMessage::Error { message } => Err(SourceError::Remote(message)),
370        }
371    }
372}
373
374impl RemoteSource {
375    /// One attempt at [`DataSource::poll`].
376    fn poll_once(&mut self) -> Result<Snapshot, SourceError> {
377        // A new connection needs time for SSH and the agent's warm-up.
378        let established = self.connection.as_ref().is_some_and(|c| c.established);
379        let timeout = if established {
380            self.interval * 3 + Duration::from_secs(5)
381        } else {
382            CONNECT_TIMEOUT
383        };
384        let deadline = Instant::now() + timeout;
385        loop {
386            // Drain everything already received; keep only the newest snapshot.
387            while let Some(message) = self.next_message(Duration::ZERO)? {
388                self.absorb(message)?;
389            }
390            if let Some(newest) = self.pending.pop_back() {
391                self.pending.clear();
392                return Ok(newest);
393            }
394            let remaining = deadline.saturating_duration_since(Instant::now());
395            if remaining.is_zero() {
396                return Err(self.disconnect(Some("no data from the agent (timed out)".to_owned())));
397            }
398            if let Some(message) = self.next_message(remaining)? {
399                self.absorb(message)?;
400            }
401        }
402    }
403}
404
405impl DataSource for RemoteSource {
406    /// Returns the newest snapshot from the agent, waiting for one if none
407    /// arrived since the previous poll.
408    fn poll(&mut self) -> Result<Snapshot, SourceError> {
409        match self.poll_once() {
410            // Not found on the PATH: try ~/.cargo/bin right away.
411            Err(_) if std::mem::take(&mut self.retry_now) => self.poll_once(),
412            result => result,
413        }
414    }
415
416    fn signal_process(&mut self, pid: u32, signal: ProcessSignal) -> Result<(), SourceError> {
417        if self
418            .connection()?
419            .send(&ClientMessage::Signal { pid, signal })
420            .is_err()
421        {
422            return Err(self.disconnect(None));
423        }
424        let deadline = Instant::now() + SIGNAL_TIMEOUT;
425        loop {
426            let remaining = deadline.saturating_duration_since(Instant::now());
427            if remaining.is_zero() {
428                return Err(SourceError::Remote(format!(
429                    "no answer to the signal for {pid}"
430                )));
431            }
432            match self.next_message(remaining)? {
433                Some(AgentMessage::SignalResult {
434                    pid: answered,
435                    error,
436                    ..
437                }) if answered == pid => {
438                    return match error {
439                        None => Ok(()),
440                        Some(error) => Err(SourceError::Remote(error)),
441                    };
442                }
443                Some(other) => self.absorb(other)?,
444                None => {}
445            }
446        }
447    }
448
449    fn set_interval(&mut self, interval: Duration) {
450        self.interval = interval;
451        if let Some(connection) = self.connection.as_mut() {
452            let _ = connection.send(&ClientMessage::SetInterval {
453                ms: interval.as_millis() as u64,
454            });
455        }
456    }
457
458    fn set_process_limit(&mut self, limit: Option<usize>) {
459        self.process_limit = limit;
460        if let Some(connection) = self.connection.as_mut() {
461            let _ = connection.send(&ClientMessage::SetProcessLimit { limit });
462        }
463    }
464}
465
466#[cfg(test)]
467mod tests {
468    use super::*;
469
470    #[test]
471    fn ssh_target_uses_batch_mode_and_keeps_the_destination_safe() {
472        let target = RemoteTarget::ssh("zefat", DEFAULT_REMOTE_COMMAND);
473        assert_eq!(target.program, "ssh");
474        assert_eq!(target.name, "zefat");
475        assert!(target.args.windows(2).any(|w| w == ["-o", "BatchMode=yes"]));
476        let n = target.args.len();
477        assert_eq!(&target.args[n - 3..], ["--", "zefat", "rigtop --agent"]);
478        // The default command falls back to ~/.cargo/bin ...
479        let fallback = target.fallback_args.as_ref().unwrap();
480        assert_eq!(&fallback[n - 3..], ["--", "zefat", FALLBACK_REMOTE_COMMAND]);
481        // ... a command the user chose does not.
482        let custom = RemoteTarget::ssh("zefat", "/opt/rigtop/bin/rigtop --agent");
483        assert_eq!(custom.fallback_args, None);
484    }
485
486    /// `sh -c <script>` standing in for ssh.
487    #[cfg(unix)]
488    fn sh_target(script: &str, fallback: Option<&str>) -> RemoteTarget {
489        let args = |script: &str| vec!["-c".to_owned(), script.to_owned()];
490        RemoteTarget {
491            name: "fake".to_owned(),
492            program: "sh".to_owned(),
493            args: args(script),
494            fallback_args: fallback.map(args),
495        }
496    }
497
498    #[cfg(unix)]
499    #[test]
500    fn command_not_found_tries_the_fallback_at_once() {
501        // Found nowhere: both attempts in one poll, and a clear message.
502        let missing = "echo 'zsh:1: command not found: rigtop' >&2; exit 127";
503        let mut source = RemoteSource::new(sh_target(missing, Some(missing)));
504        let err = source.poll().unwrap_err().to_string();
505        assert!(
506            err.contains("neither on the PATH nor in ~/.cargo/bin"),
507            "{err}"
508        );
509        assert!(!source.use_fallback, "the PATH comes first again next time");
510
511        // Other failures do not switch to the fallback.
512        let mut source = RemoteSource::new(sh_target(
513            "echo 'Permission denied (publickey)' >&2; exit 255",
514            Some(missing),
515        ));
516        let err = source.poll().unwrap_err().to_string();
517        assert!(err.contains("Permission denied"), "{err}");
518        assert!(!source.use_fallback);
519    }
520
521    #[test]
522    fn missing_program_is_reported_and_retried_later() {
523        let mut source = RemoteSource::new(RemoteTarget {
524            name: "nowhere".to_owned(),
525            program: "/nonexistent/ssh".to_owned(),
526            args: Vec::new(),
527            fallback_args: None,
528        });
529        let err = source.poll().unwrap_err().to_string();
530        assert!(err.contains("cannot start"), "{err}");
531        // The next attempt waits for the reconnect delay, repeating the cause.
532        let err = source.poll().unwrap_err().to_string();
533        assert!(
534            err.contains("retrying") && err.contains("cannot start"),
535            "{err}"
536        );
537    }
538
539    #[cfg(unix)]
540    #[test]
541    fn stderr_explains_an_agent_that_exits() {
542        // Stands in for ssh failing, e.g. "rigtop: command not found".
543        let mut source = RemoteSource::new(RemoteTarget {
544            name: "fake".to_owned(),
545            program: "sh".to_owned(),
546            args: vec![
547                "-c".to_owned(),
548                "echo 'rigtop: command not found' >&2; exit 127".to_owned(),
549            ],
550            fallback_args: None,
551        });
552        let err = source.poll().unwrap_err().to_string();
553        assert!(err.contains("fake: rigtop: command not found"), "{err}");
554    }
555
556    #[cfg(unix)]
557    #[test]
558    fn incompatible_protocol_is_rejected() {
559        let mut source = RemoteSource::new(RemoteTarget {
560            name: "old".to_owned(),
561            program: "sh".to_owned(),
562            args: vec![
563                "-c".to_owned(),
564                r#"echo '{"type":"hello","protocol":999,"version":"9.9.9"}'; sleep 5"#.to_owned(),
565            ],
566            fallback_args: None,
567        });
568        let err = source.poll().unwrap_err().to_string();
569        assert!(err.contains("protocol 999"), "{err}");
570    }
571
572    #[cfg(unix)]
573    #[test]
574    fn old_agents_get_their_version_from_the_greeting() {
575        // An agent before 0.9.1: no version in its snapshots.
576        let mut source = RemoteSource::new(RemoteTarget {
577            name: "old".to_owned(),
578            program: "sh".to_owned(),
579            args: vec![
580                "-c".to_owned(),
581                r#"echo '{"type":"hello","protocol":1,"version":"0.8.2"}'
582                   echo '{"type":"snapshot","snapshot":{"host":{"hostname":"nas"}}}'
583                   sleep 5"#
584                    .to_owned(),
585            ],
586            fallback_args: None,
587        });
588        let snapshot = source.poll().unwrap();
589        assert_eq!(snapshot.host.hostname, "nas");
590        assert_eq!(snapshot.host.rigtop_version, "0.8.2");
591        assert_eq!(
592            crate::view::version_note(&snapshot).as_deref(),
593            Some("agent 0.8.2")
594        );
595    }
596}