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 in order when the default command is not found: the install
23/// script puts rigtop in `~/.local/bin` and `cargo install` in
24/// `~/.cargo/bin`, which non-interactive SSH sessions often lack on their
25/// `PATH`. Release binaries come first: they update without building.
26pub const FALLBACK_REMOTE_COMMANDS: [&str; 2] =
27    ["~/.local/bin/rigtop --agent", "~/.cargo/bin/rigtop --agent"];
28/// Exit status of POSIX shells for "command not found".
29const NOT_FOUND_STATUS: i32 = 127;
30
31/// Minimum time between connection attempts after a failure.
32const RECONNECT_DELAY: Duration = Duration::from_secs(5);
33/// How long to wait for the first snapshot of a new connection (SSH
34/// handshake plus the agent's warm-up sample).
35const CONNECT_TIMEOUT: Duration = Duration::from_secs(20);
36/// How long to wait for the agent to answer a signal request.
37const SIGNAL_TIMEOUT: Duration = Duration::from_secs(10);
38/// stderr lines kept to explain failures.
39const STDERR_LINES: usize = 20;
40
41/// How to start the agent: a program and its arguments.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct RemoteTarget {
44    /// Short name for messages (e.g. the SSH destination).
45    pub name: String,
46    /// Program to run (normally `ssh`).
47    pub program: String,
48    /// Its arguments.
49    pub args: Vec<String>,
50    /// Arguments to try in order when the remote command is not found
51    /// (for the default command: [`FALLBACK_REMOTE_COMMANDS`]).
52    pub fallback_args: Vec<Vec<String>>,
53}
54
55impl RemoteTarget {
56    /// Runs `remote_command` on `destination` (anything `ssh` accepts:
57    /// a `~/.ssh/config` host, `user@host`, ...).
58    pub fn ssh(destination: &str, remote_command: &str) -> Self {
59        let options = [
60            // No password or host key prompts: fail instead of hanging.
61            "BatchMode=yes",
62            "ConnectTimeout=10",
63            // Notice dead connections within ~45 s.
64            "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        // `--` keeps a destination starting with "-" from being an option.
73        args.push("--".to_owned());
74        args.push(destination.to_owned());
75        args.push(remote_command.to_owned());
76        // Only the default command falls back; one set by the user is
77        // taken as it is.
78        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
99/// What the reader thread passes on.
100enum Incoming {
101    Message(AgentMessage),
102    /// stdout closed: the agent or ssh exited.
103    Closed,
104}
105
106/// One running agent process.
107struct Connection {
108    child: Child,
109    stdin: ChildStdin,
110    incoming: Receiver<Incoming>,
111    /// Last lines of the process's stderr (ssh errors, "command not found").
112    stderr: Arc<Mutex<VecDeque<String>>>,
113    /// Whether a snapshot has arrived on this connection yet.
114    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            // CREATE_NO_WINDOW: a GUI must not flash a console for ssh.exe.
129            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                    // Lines that are not protocol messages (e.g. a login
145                    // banner printed by a shell profile) are skipped.
146                    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    /// Explanation for a lost connection, from the process's stderr, and
186    /// whether the remote command was not found.
187    fn failure(&mut self) -> (String, bool) {
188        // Give the stderr thread a moment to collect the final lines.
189        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        // ssh passes the remote exit status on; cmd.exe says "not recognized".
199        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        // Closing stdin makes the agent exit; killing ssh ends the session
214        // right away instead of waiting for it.
215        let _ = self.child.kill();
216        let _ = self.child.wait();
217    }
218}
219
220/// Snapshots from `rigtop --agent` on another machine.
221pub struct RemoteSource {
222    target: RemoteTarget,
223    connection: Option<Connection>,
224    last_attempt: Option<Instant>,
225    interval: Duration,
226    process_limit: Option<usize>,
227    /// Snapshots received while waiting for something else (signal results).
228    pending: VecDeque<Snapshot>,
229    /// rigtop version from the agent's greeting.
230    agent_version: String,
231    /// Why the last connection failed, repeated while waiting to retry.
232    last_error: Option<String>,
233    /// Which command starts the agent: 0 for the configured one, then the
234    /// fallbacks in order.
235    attempt: usize,
236    /// The command was not found and the next one should be tried at once.
237    retry_now: bool,
238}
239
240impl RemoteSource {
241    /// Creates a source; the connection is made on the first poll.
242    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    /// The target this source connects to.
258    pub fn target(&self) -> &RemoteTarget {
259        &self.target
260    }
261
262    /// Returns the connection, (re)connecting if needed and allowed.
263    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            // Agents start with their own default; send our settings.
296            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                // ssh already exited (bad host, auth failure): its stderr
308                // says why, which beats "broken pipe".
309                return Err(self.disconnect(None));
310            }
311        }
312        Ok(self.connection.as_mut().expect("connected above"))
313    }
314
315    /// Drops the connection and returns an error explaining why.
316    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                // None worked; start over with the PATH next time, in case
330                // rigtop gets installed there.
331                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    /// Waits up to `timeout` for the next message.
343    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    /// Handles messages that are not what the caller waits for. Returns an
355    /// error for fatal ones (protocol mismatch).
356    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                // Do not retry quickly against an incompatible agent.
364                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                // Agents before 0.9.1 do not put their version in snapshots.
374                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    /// One attempt at [`DataSource::poll`].
391    fn poll_once(&mut self) -> Result<Snapshot, SourceError> {
392        // A new connection needs time for SSH and the agent's warm-up.
393        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            // Drain everything already received; keep only the newest snapshot.
402            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    /// Returns the newest snapshot from the agent, waiting for one if none
422    /// arrived since the previous poll.
423    fn poll(&mut self) -> Result<Snapshot, SourceError> {
424        loop {
425            match self.poll_once() {
426                // Not found: try the next place (~/.local/bin, then
427                // ~/.cargo/bin) right away.
428                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        // The default command falls back to ~/.local/bin, then ~/.cargo/bin ...
497        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        // ... a command the user chose does not.
505        let custom = RemoteTarget::ssh("zefat", "/opt/rigtop/bin/rigtop --agent");
506        assert!(custom.fallback_args.is_empty());
507    }
508
509    /// `sh -c <script>` standing in for ssh.
510    #[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        // Found nowhere: every attempt in one poll, and a clear message.
525        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        // Other failures do not switch to the fallbacks.
535        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        // The next attempt waits for the reconnect delay, repeating the cause.
555        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        // Stands in for ssh failing, e.g. "rigtop: command not found".
566        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        // An agent before 0.9.1: no version in its snapshots.
599        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}