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