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
23/// Minimum time between connection attempts after a failure.
24const RECONNECT_DELAY: Duration = Duration::from_secs(5);
25/// How long to wait for the first snapshot of a new connection (SSH
26/// handshake plus the agent's warm-up sample).
27const CONNECT_TIMEOUT: Duration = Duration::from_secs(20);
28/// How long to wait for the agent to answer a signal request.
29const SIGNAL_TIMEOUT: Duration = Duration::from_secs(10);
30/// stderr lines kept to explain failures.
31const STDERR_LINES: usize = 20;
32
33/// How to start the agent: a program and its arguments.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct RemoteTarget {
36    /// Short name for messages (e.g. the SSH destination).
37    pub name: String,
38    /// Program to run (normally `ssh`).
39    pub program: String,
40    /// Its arguments.
41    pub args: Vec<String>,
42}
43
44impl RemoteTarget {
45    /// Runs `remote_command` on `destination` (anything `ssh` accepts:
46    /// a `~/.ssh/config` host, `user@host`, ...).
47    pub fn ssh(destination: &str, remote_command: &str) -> Self {
48        let options = [
49            // No password or host key prompts: fail instead of hanging.
50            "BatchMode=yes",
51            "ConnectTimeout=10",
52            // Notice dead connections within ~45 s.
53            "ServerAliveInterval=15",
54            "ServerAliveCountMax=3",
55        ];
56        let mut args = vec!["-T".to_owned()];
57        for option in options {
58            args.push("-o".to_owned());
59            args.push(option.to_owned());
60        }
61        // `--` keeps a destination starting with "-" from being an option.
62        args.push("--".to_owned());
63        args.push(destination.to_owned());
64        args.push(remote_command.to_owned());
65        Self {
66            name: destination.to_owned(),
67            program: "ssh".to_owned(),
68            args,
69        }
70    }
71}
72
73/// What the reader thread passes on.
74enum Incoming {
75    Message(AgentMessage),
76    /// stdout closed: the agent or ssh exited.
77    Closed,
78}
79
80/// One running agent process.
81struct Connection {
82    child: Child,
83    stdin: ChildStdin,
84    incoming: Receiver<Incoming>,
85    /// Last lines of the process's stderr (ssh errors, "command not found").
86    stderr: Arc<Mutex<VecDeque<String>>>,
87    /// Whether a snapshot has arrived on this connection yet.
88    established: bool,
89}
90
91impl Connection {
92    fn spawn(target: &RemoteTarget) -> Result<Self, SourceError> {
93        let mut command = Command::new(&target.program);
94        command
95            .args(&target.args)
96            .stdin(Stdio::piped())
97            .stdout(Stdio::piped())
98            .stderr(Stdio::piped());
99        #[cfg(windows)]
100        {
101            use std::os::windows::process::CommandExt;
102            // CREATE_NO_WINDOW: a GUI must not flash a console for ssh.exe.
103            command.creation_flags(0x0800_0000);
104        }
105        let mut child = command.spawn().map_err(|err| {
106            SourceError::Disconnected(format!("cannot start {}: {err}", target.program))
107        })?;
108        let stdin = child.stdin.take().expect("piped stdin");
109        let stdout = child.stdout.take().expect("piped stdout");
110        let stderr_pipe = child.stderr.take().expect("piped stderr");
111
112        let (tx, incoming) = mpsc::channel();
113        thread::Builder::new()
114            .name("rigtop-remote-reader".to_owned())
115            .spawn(move || {
116                for line in BufReader::new(stdout).lines() {
117                    let Ok(line) = line else { break };
118                    // Lines that are not protocol messages (e.g. a login
119                    // banner printed by a shell profile) are skipped.
120                    if let Ok(message) = serde_json::from_str::<AgentMessage>(&line)
121                        && tx.send(Incoming::Message(message)).is_err()
122                    {
123                        return;
124                    }
125                }
126                let _ = tx.send(Incoming::Closed);
127            })
128            .map_err(SourceError::Io)?;
129
130        let stderr = Arc::new(Mutex::new(VecDeque::new()));
131        let lines = Arc::clone(&stderr);
132        thread::Builder::new()
133            .name("rigtop-remote-stderr".to_owned())
134            .spawn(move || {
135                for line in BufReader::new(stderr_pipe).lines() {
136                    let Ok(line) = line else { break };
137                    let mut lines = lines.lock().unwrap_or_else(|e| e.into_inner());
138                    if lines.len() == STDERR_LINES {
139                        lines.pop_front();
140                    }
141                    lines.push_back(line);
142                }
143            })
144            .map_err(SourceError::Io)?;
145
146        Ok(Self {
147            child,
148            stdin,
149            incoming,
150            stderr,
151            established: false,
152        })
153    }
154
155    fn send(&mut self, message: &ClientMessage) -> Result<(), SourceError> {
156        write_message(&mut self.stdin, message).map_err(SourceError::Io)
157    }
158
159    /// Explanation for a lost connection, from the process's stderr.
160    fn failure_reason(&mut self) -> String {
161        // Give the stderr thread a moment to collect the final lines.
162        thread::sleep(Duration::from_millis(100));
163        let status = self.child.try_wait().ok().flatten();
164        let lines = self.stderr.lock().unwrap_or_else(|e| e.into_inner());
165        let detail = lines
166            .iter()
167            .rev()
168            .find(|l| !l.trim().is_empty())
169            .cloned()
170            .unwrap_or_default();
171        match (detail.is_empty(), status) {
172            (false, _) => detail,
173            (true, Some(status)) => format!("connection closed ({status})"),
174            (true, None) => "connection closed".to_owned(),
175        }
176    }
177}
178
179impl Drop for Connection {
180    fn drop(&mut self) {
181        // Closing stdin makes the agent exit; killing ssh ends the session
182        // right away instead of waiting for it.
183        let _ = self.child.kill();
184        let _ = self.child.wait();
185    }
186}
187
188/// Snapshots from `rigtop --agent` on another machine.
189pub struct RemoteSource {
190    target: RemoteTarget,
191    connection: Option<Connection>,
192    last_attempt: Option<Instant>,
193    interval: Duration,
194    process_limit: Option<usize>,
195    /// Snapshots received while waiting for something else (signal results).
196    pending: VecDeque<Snapshot>,
197    /// Why the last connection failed, repeated while waiting to retry.
198    last_error: Option<String>,
199}
200
201impl RemoteSource {
202    /// Creates a source; the connection is made on the first poll.
203    pub fn new(target: RemoteTarget) -> Self {
204        Self {
205            target,
206            connection: None,
207            last_attempt: None,
208            interval: Duration::from_secs(1),
209            process_limit: None,
210            pending: VecDeque::new(),
211            last_error: None,
212        }
213    }
214
215    /// The target this source connects to.
216    pub fn target(&self) -> &RemoteTarget {
217        &self.target
218    }
219
220    /// Returns the connection, (re)connecting if needed and allowed.
221    fn connection(&mut self) -> Result<&mut Connection, SourceError> {
222        if self.connection.is_none() {
223            if let Some(at) = self.last_attempt {
224                let wait = RECONNECT_DELAY.saturating_sub(at.elapsed());
225                if !wait.is_zero() {
226                    let reason = self.last_error.as_deref().unwrap_or("not connected");
227                    return Err(SourceError::Disconnected(format!(
228                        "{}: {reason} (retrying in {}s)",
229                        self.target.name,
230                        wait.as_secs() + 1
231                    )));
232                }
233            }
234            self.last_attempt = Some(Instant::now());
235            let mut connection = match Connection::spawn(&self.target) {
236                Ok(connection) => connection,
237                Err(err) => {
238                    self.last_error = Some(match &err {
239                        SourceError::Disconnected(reason) => reason.clone(),
240                        other => other.to_string(),
241                    });
242                    return Err(err);
243                }
244            };
245            // Agents start with their own default; send our settings.
246            let sent = connection
247                .send(&ClientMessage::SetInterval {
248                    ms: self.interval.as_millis() as u64,
249                })
250                .and_then(|()| {
251                    connection.send(&ClientMessage::SetProcessLimit {
252                        limit: self.process_limit,
253                    })
254                });
255            self.connection = Some(connection);
256            if sent.is_err() {
257                // ssh already exited (bad host, auth failure): its stderr
258                // says why, which beats "broken pipe".
259                return Err(self.disconnect(None));
260            }
261        }
262        Ok(self.connection.as_mut().expect("connected above"))
263    }
264
265    /// Drops the connection and returns an error explaining why.
266    fn disconnect(&mut self, reason: Option<String>) -> SourceError {
267        let reason = match (reason, self.connection.as_mut()) {
268            (Some(reason), _) => reason,
269            (None, Some(connection)) => connection.failure_reason(),
270            (None, None) => "connection closed".to_owned(),
271        };
272        self.connection = None;
273        let error = SourceError::Disconnected(format!("{}: {reason}", self.target.name));
274        self.last_error = Some(reason);
275        error
276    }
277
278    /// Waits up to `timeout` for the next message.
279    fn next_message(&mut self, timeout: Duration) -> Result<Option<AgentMessage>, SourceError> {
280        let connection = self.connection()?;
281        match connection.incoming.recv_timeout(timeout) {
282            Ok(Incoming::Message(message)) => Ok(Some(message)),
283            Ok(Incoming::Closed) | Err(RecvTimeoutError::Disconnected) => {
284                Err(self.disconnect(None))
285            }
286            Err(RecvTimeoutError::Timeout) => Ok(None),
287        }
288    }
289
290    /// Handles messages that are not what the caller waits for. Returns an
291    /// error for fatal ones (protocol mismatch).
292    fn absorb(&mut self, message: AgentMessage) -> Result<(), SourceError> {
293        match message {
294            AgentMessage::Hello { protocol, version } if protocol != PROTOCOL_VERSION => {
295                let reason = format!(
296                    "agent speaks protocol {protocol} (rigtop {version}), this client speaks {PROTOCOL_VERSION}; \
297                     install the same rigtop version on both sides"
298                );
299                // Do not retry quickly against an incompatible agent.
300                self.last_attempt = Some(Instant::now());
301                Err(self.disconnect(Some(reason)))
302            }
303            AgentMessage::Hello { .. } | AgentMessage::SignalResult { .. } => Ok(()),
304            AgentMessage::Snapshot { snapshot } => {
305                if let Some(connection) = self.connection.as_mut() {
306                    connection.established = true;
307                }
308                self.last_error = None;
309                self.pending.push_back(*snapshot);
310                Ok(())
311            }
312            AgentMessage::Error { message } => Err(SourceError::Remote(message)),
313        }
314    }
315}
316
317impl DataSource for RemoteSource {
318    /// Returns the newest snapshot from the agent, waiting for one if none
319    /// arrived since the previous poll.
320    fn poll(&mut self) -> Result<Snapshot, SourceError> {
321        // A new connection needs time for SSH and the agent's warm-up.
322        let established = self.connection.as_ref().is_some_and(|c| c.established);
323        let timeout = if established {
324            self.interval * 3 + Duration::from_secs(5)
325        } else {
326            CONNECT_TIMEOUT
327        };
328        let deadline = Instant::now() + timeout;
329        loop {
330            // Drain everything already received; keep only the newest snapshot.
331            while let Some(message) = self.next_message(Duration::ZERO)? {
332                self.absorb(message)?;
333            }
334            if let Some(newest) = self.pending.pop_back() {
335                self.pending.clear();
336                return Ok(newest);
337            }
338            let remaining = deadline.saturating_duration_since(Instant::now());
339            if remaining.is_zero() {
340                return Err(self.disconnect(Some("no data from the agent (timed out)".to_owned())));
341            }
342            if let Some(message) = self.next_message(remaining)? {
343                self.absorb(message)?;
344            }
345        }
346    }
347
348    fn signal_process(&mut self, pid: u32, signal: ProcessSignal) -> Result<(), SourceError> {
349        if self
350            .connection()?
351            .send(&ClientMessage::Signal { pid, signal })
352            .is_err()
353        {
354            return Err(self.disconnect(None));
355        }
356        let deadline = Instant::now() + SIGNAL_TIMEOUT;
357        loop {
358            let remaining = deadline.saturating_duration_since(Instant::now());
359            if remaining.is_zero() {
360                return Err(SourceError::Remote(format!(
361                    "no answer to the signal for {pid}"
362                )));
363            }
364            match self.next_message(remaining)? {
365                Some(AgentMessage::SignalResult {
366                    pid: answered,
367                    error,
368                    ..
369                }) if answered == pid => {
370                    return match error {
371                        None => Ok(()),
372                        Some(error) => Err(SourceError::Remote(error)),
373                    };
374                }
375                Some(other) => self.absorb(other)?,
376                None => {}
377            }
378        }
379    }
380
381    fn set_interval(&mut self, interval: Duration) {
382        self.interval = interval;
383        if let Some(connection) = self.connection.as_mut() {
384            let _ = connection.send(&ClientMessage::SetInterval {
385                ms: interval.as_millis() as u64,
386            });
387        }
388    }
389
390    fn set_process_limit(&mut self, limit: Option<usize>) {
391        self.process_limit = limit;
392        if let Some(connection) = self.connection.as_mut() {
393            let _ = connection.send(&ClientMessage::SetProcessLimit { limit });
394        }
395    }
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401
402    #[test]
403    fn ssh_target_uses_batch_mode_and_keeps_the_destination_safe() {
404        let target = RemoteTarget::ssh("zefat", DEFAULT_REMOTE_COMMAND);
405        assert_eq!(target.program, "ssh");
406        assert_eq!(target.name, "zefat");
407        assert!(target.args.windows(2).any(|w| w == ["-o", "BatchMode=yes"]));
408        let n = target.args.len();
409        assert_eq!(&target.args[n - 3..], ["--", "zefat", "rigtop --agent"]);
410    }
411
412    #[test]
413    fn missing_program_is_reported_and_retried_later() {
414        let mut source = RemoteSource::new(RemoteTarget {
415            name: "nowhere".to_owned(),
416            program: "/nonexistent/ssh".to_owned(),
417            args: Vec::new(),
418        });
419        let err = source.poll().unwrap_err().to_string();
420        assert!(err.contains("cannot start"), "{err}");
421        // The next attempt waits for the reconnect delay, repeating the cause.
422        let err = source.poll().unwrap_err().to_string();
423        assert!(
424            err.contains("retrying") && err.contains("cannot start"),
425            "{err}"
426        );
427    }
428
429    #[cfg(unix)]
430    #[test]
431    fn stderr_explains_an_agent_that_exits() {
432        // Stands in for ssh failing, e.g. "rigtop: command not found".
433        let mut source = RemoteSource::new(RemoteTarget {
434            name: "fake".to_owned(),
435            program: "sh".to_owned(),
436            args: vec![
437                "-c".to_owned(),
438                "echo 'rigtop: command not found' >&2; exit 127".to_owned(),
439            ],
440        });
441        let err = source.poll().unwrap_err().to_string();
442        assert!(err.contains("fake: rigtop: command not found"), "{err}");
443    }
444
445    #[cfg(unix)]
446    #[test]
447    fn incompatible_protocol_is_rejected() {
448        let mut source = RemoteSource::new(RemoteTarget {
449            name: "old".to_owned(),
450            program: "sh".to_owned(),
451            args: vec![
452                "-c".to_owned(),
453                r#"echo '{"type":"hello","protocol":999,"version":"9.9.9"}'; sleep 5"#.to_owned(),
454            ],
455        });
456        let err = source.poll().unwrap_err().to_string();
457        assert!(err.contains("protocol 999"), "{err}");
458    }
459}