Skip to main content

farhand_core/
remote.rs

1//! Everything that touches the remote host: one multiplexed OpenSSH
2//! session, an SFTP channel on top of it, and the operations the tools are
3//! built from.
4//!
5//! Design rules:
6//! - Authentication is OpenSSH's job. FarHand starts `ssh <host>` and lets
7//!   the user's own config supply keys, agents, jump hosts and ports.
8//! - Command text is never shell-escaped by hand. It is base64-encoded and
9//!   decoded on the remote, so quoting cannot go wrong.
10//! - The local environment is never forwarded.
11//! - Every command runs under the remote `timeout(1)` with a local watchdog
12//!   behind it.
13
14use std::path::Path;
15use std::sync::Arc;
16use std::time::{Duration, Instant};
17
18use base64::Engine;
19use bytes::BytesMut;
20use openssh::{KnownHosts, Session, SessionBuilder, Stdio};
21use openssh_sftp_client::{Sftp, SftpOptions};
22use tokio::io::AsyncReadExt;
23use tokio::sync::{Mutex, Semaphore};
24
25use crate::config::{Limits, RemoteConfig};
26use crate::error::{Error, Result};
27use crate::text::shell_quote;
28
29/// Remote `timeout(1)` exit statuses.
30const TIMEOUT_EXIT: i32 = 124;
31/// Extra seconds the local watchdog waits past the remote timeout.
32const WATCHDOG_GRACE: u64 = 15;
33/// Exit status the wrapper uses when `cd` into the working directory fails.
34const CD_FAILED_EXIT: i32 = 97;
35/// Concurrent channels on the one session. sshd's default `MaxSessions` is
36/// 10 and the SFTP channel holds one permanently; agents that fan out tool
37/// calls otherwise hit "failed to connect to the ssh multiplex server".
38const MAX_CHANNELS: usize = 4;
39
40/// The remote as reachable from the local machine.
41#[derive(Debug, Clone)]
42pub struct Address {
43    pub hostname: String,
44    pub port: u16,
45    /// The destination as written in the config (an alias or `user@host`).
46    pub alias: String,
47}
48
49#[derive(Debug, Clone)]
50pub struct ExecOutput {
51    pub exit_code: i32,
52    pub stdout: Vec<u8>,
53    pub stderr: Vec<u8>,
54    pub stdout_truncated: bool,
55    pub stderr_truncated: bool,
56    pub timed_out: bool,
57    pub duration: Duration,
58}
59
60struct Conn {
61    session: Arc<Session>,
62    sftp: Sftp,
63    home: String,
64    /// The configured workdir with `~` expanded to `home`.
65    workdir: String,
66}
67
68pub struct Remote {
69    cfg: RemoteConfig,
70    limits: Limits,
71    conn: Mutex<Option<Arc<Conn>>>,
72    channels: Semaphore,
73}
74
75impl Remote {
76    pub fn new(cfg: RemoteConfig, limits: Limits) -> Self {
77        Self {
78            cfg,
79            limits,
80            conn: Mutex::new(None),
81            channels: Semaphore::new(MAX_CHANNELS),
82        }
83    }
84
85    pub fn host(&self) -> &str {
86        &self.cfg.host
87    }
88
89    /// How the remote is addressed from this machine, as OpenSSH resolves
90    /// the destination: `(hostname, port)`. The model needs this to report
91    /// URLs the user can open; an alias from `~/.ssh/config` means nothing
92    /// to a browser. Falls back to the configured destination.
93    pub fn address(&self) -> Address {
94        let fallback = Address {
95            hostname: self
96                .cfg
97                .host
98                .rsplit('@')
99                .next()
100                .unwrap_or(&self.cfg.host)
101                .to_string(),
102            port: 22,
103            alias: self.cfg.host.clone(),
104        };
105        let out = match std::process::Command::new("ssh")
106            .arg("-G")
107            .arg(&self.cfg.host)
108            .stdin(std::process::Stdio::null())
109            .stderr(std::process::Stdio::null())
110            .output()
111        {
112            Ok(o) if o.status.success() => o.stdout,
113            _ => return fallback,
114        };
115        let text = String::from_utf8_lossy(&out);
116        let mut addr = fallback;
117        for line in text.lines() {
118            match line.split_once(' ') {
119                Some(("hostname", v)) => addr.hostname = v.trim().to_string(),
120                Some(("port", v)) => addr.port = v.trim().parse().unwrap_or(22),
121                _ => {}
122            }
123        }
124        addr
125    }
126
127    /// The workdir as configured (`~` not yet expanded).
128    pub fn workdir(&self) -> &str {
129        &self.cfg.workdir
130    }
131
132    /// The workdir with `~` expanded; connects if needed.
133    pub async fn effective_workdir(&self) -> Result<String> {
134        Ok(self.conn().await?.workdir.clone())
135    }
136
137    pub fn limits(&self) -> &Limits {
138        &self.limits
139    }
140
141    /// Connect now, or report why that is impossible.
142    pub async fn connect(&self) -> Result<()> {
143        self.conn().await.map(|_| ())
144    }
145
146    pub async fn is_connected(&self) -> bool {
147        match &*self.conn.lock().await {
148            Some(c) => c.session.check().await.is_ok(),
149            None => false,
150        }
151    }
152
153    async fn conn(&self) -> Result<Arc<Conn>> {
154        let mut slot = self.conn.lock().await;
155        if let Some(c) = &*slot {
156            if c.session.check().await.is_ok() {
157                return Ok(c.clone());
158            }
159            tracing::warn!("ssh session to {} dropped; reconnecting", self.cfg.host);
160            *slot = None;
161        }
162        // Strict host-key checking: FarHand only talks to hosts the user has
163        // already connected to by hand. A `.farhand.toml` that arrives with a
164        // cloned repository therefore cannot point an agent at a machine the
165        // user never chose.
166        let mut builder = SessionBuilder::default();
167        builder
168            .connect_timeout(Duration::from_secs(self.cfg.connect_timeout_secs))
169            .server_alive_interval(Duration::from_secs(self.cfg.server_alive_interval_secs))
170            .known_hosts_check(KnownHosts::Strict);
171        let session = builder.connect_mux(&self.cfg.host).await.map_err(|e| {
172            Error::Ssh(format!(
173                "cannot connect to `{}`: {e}. Check that `ssh {}` works from a terminal; \
174                 FarHand refuses hosts whose key is not yet in known_hosts, so connect once \
175                 by hand first.",
176                self.cfg.host, self.cfg.host
177            ))
178        })?;
179        let session = Arc::new(session);
180        let sftp = Sftp::from_clonable_session(session.clone(), SftpOptions::default())
181            .await
182            .map_err(|e| {
183                Error::Ssh(format!(
184                    "sftp subsystem unavailable on `{}`: {e}",
185                    self.cfg.host
186                ))
187            })?;
188        let home = {
189            let out = session
190                .command("sh")
191                .arg("-c")
192                .arg("printf %s \"$HOME\"")
193                .output()
194                .await?;
195            String::from_utf8_lossy(&out.stdout).trim().to_string()
196        };
197        let workdir = expand_tilde(&self.cfg.workdir, &home);
198        let conn = Arc::new(Conn {
199            session,
200            sftp,
201            home,
202            workdir,
203        });
204        *slot = Some(conn.clone());
205        tracing::info!(host = %self.cfg.host, "connected");
206        Ok(conn)
207    }
208
209    /// Absolute remote path for what the model typed: `~` expands to the
210    /// remote home, relative paths hang off the working directory.
211    pub async fn resolve(&self, raw: &str) -> Result<String> {
212        let raw = raw.trim();
213        if raw.starts_with('/') {
214            return Ok(normalize(raw));
215        }
216        let conn = self.conn().await?;
217        if raw.is_empty() {
218            return Ok(conn.workdir.clone());
219        }
220        if raw == "~" || raw.starts_with("~/") {
221            return Ok(normalize(&expand_tilde(raw, &conn.home)));
222        }
223        Ok(normalize(&format!("{}/{raw}", conn.workdir)))
224    }
225
226    /// Run `command` through the configured shell in `cwd` (default: the
227    /// working directory). `timeout_secs` is clamped to the configured
228    /// maximum.
229    pub async fn exec(
230        &self,
231        command: &str,
232        cwd: Option<&str>,
233        timeout_secs: Option<u64>,
234    ) -> Result<ExecOutput> {
235        let conn = self.conn().await?;
236        let cwd = match cwd {
237            Some(c) => self.resolve(c).await?,
238            None => conn.workdir.clone(),
239        };
240        let timeout = timeout_secs
241            .unwrap_or(self.limits.command_timeout_secs)
242            .clamp(1, self.limits.max_command_timeout_secs);
243
244        // The script the user's shell runs. Colour is off because the model
245        // reads the output; nothing else about the remote environment is
246        // touched, and nothing local is forwarded.
247        let script = format!(
248            "cd {} || exit {CD_FAILED_EXIT}\nexport NO_COLOR=1\n{command}\n",
249            shell_quote(&cwd)
250        );
251        let b64 = base64::engine::general_purpose::STANDARD.encode(script.as_bytes());
252        let shell = self
253            .cfg
254            .shell
255            .iter()
256            .map(|s| format!("\"{}\"", s.replace('"', "\\\"")))
257            .collect::<Vec<_>>()
258            .join(" ");
259        // Outer `sh -c` so the remote login shell only has to parse one
260        // single-quoted word; the inner script has no single quotes because
261        // base64 has none and the shell argv is double-quoted.
262        let inner = format!("timeout -k 5 {timeout} {shell} \"$(printf %s {b64} | base64 -d)\"");
263
264        let _permit = self.channels.acquire().await.expect("semaphore open");
265        let started = Instant::now();
266        let mut cmd = conn.session.raw_command("sh");
267        cmd.raw_arg("-c")
268            .raw_arg(format!("'{inner}'"))
269            .stdin(Stdio::null())
270            .stdout(Stdio::piped())
271            .stderr(Stdio::piped());
272        let mut child = cmd.spawn().await?;
273        let stdout = child.stdout().take().expect("piped stdout");
274        let stderr = child.stderr().take().expect("piped stderr");
275        let cap = self.limits.max_output_bytes;
276
277        let collect = async {
278            let (out, err) =
279                tokio::join!(collect_bounded(stdout, cap), collect_bounded(stderr, cap));
280            let status = child.wait().await?;
281            Ok::<_, Error>((out?, err?, status))
282        };
283        let watchdog = Duration::from_secs(timeout + WATCHDOG_GRACE);
284        let (out, err, status) = match tokio::time::timeout(watchdog, collect).await {
285            Ok(r) => r?,
286            Err(_) => {
287                tracing::warn!("local watchdog fired after {}s", watchdog.as_secs());
288                return Err(Error::Timeout { secs: timeout });
289            }
290        };
291        let exit_code = status.code().unwrap_or(-1);
292        let timed_out = exit_code == TIMEOUT_EXIT;
293        if exit_code == CD_FAILED_EXIT && out.data.is_empty() {
294            return Err(Error::Invalid(format!(
295                "cannot cd into `{cwd}` on the remote"
296            )));
297        }
298        Ok(ExecOutput {
299            exit_code,
300            stdout: out.data,
301            stderr: err.data,
302            stdout_truncated: out.truncated,
303            stderr_truncated: err.truncated,
304            timed_out,
305            duration: started.elapsed(),
306        })
307    }
308
309    /// Read at most `max` bytes of a remote file. Returns the bytes and the
310    /// file's full length.
311    pub async fn read_file(&self, path: &str, max: usize) -> Result<(Vec<u8>, u64)> {
312        let conn = self.conn().await?;
313        let path = self.resolve(path).await?;
314        let _permit = self.channels.acquire().await.expect("semaphore open");
315        let mut fs = conn.sftp.fs();
316        let meta = fs.metadata(&path).await.map_err(|e| not_found(&path, e))?;
317        if meta.file_type().map(|t| t.is_dir()).unwrap_or(false) {
318            return Err(Error::Invalid(format!("`{path}` is a directory")));
319        }
320        let len = meta.len().unwrap_or(0);
321        let mut file = conn
322            .sftp
323            .open(&path)
324            .await
325            .map_err(|e| not_found(&path, e))?;
326        let want = (len as usize).min(max);
327        let mut buf = BytesMut::with_capacity(want);
328        let mut remaining = want;
329        while remaining > 0 {
330            let chunk = remaining.min(256 * 1024) as u32;
331            match file.read(chunk, buf.split_off(buf.len())).await? {
332                Some(bytes) => {
333                    remaining -= bytes.len();
334                    buf.unsplit(bytes);
335                }
336                None => break,
337            }
338        }
339        let _ = file.close().await;
340        Ok((buf.to_vec(), len))
341    }
342
343    /// Write a whole file, creating parent directories.
344    pub async fn write_file(&self, path: &str, content: &[u8]) -> Result<String> {
345        let conn = self.conn().await?;
346        let path = self.resolve(path).await?;
347        if let Some(parent) = Path::new(&path).parent() {
348            let mkdir = format!("mkdir -p {}", shell_quote(&parent.to_string_lossy()));
349            let out = self.exec(&mkdir, None, Some(30)).await?;
350            if out.exit_code != 0 {
351                return Err(Error::Ssh(format!(
352                    "mkdir -p failed: {}",
353                    String::from_utf8_lossy(&out.stderr).trim()
354                )));
355            }
356        }
357        let _permit = self.channels.acquire().await.expect("semaphore open");
358        conn.sftp.fs().write(&path, content).await?;
359        Ok(path)
360    }
361
362    pub async fn exists(&self, path: &str) -> Result<Option<bool>> {
363        let conn = self.conn().await?;
364        let path = self.resolve(path).await?;
365        let _permit = self.channels.acquire().await.expect("semaphore open");
366        match conn.sftp.fs().metadata(&path).await {
367            Ok(m) => Ok(Some(m.file_type().map(|t| t.is_dir()).unwrap_or(false))),
368            Err(_) => Ok(None),
369        }
370    }
371
372    /// Whether a remote executable is on PATH.
373    pub async fn has_tool(&self, name: &str) -> Result<bool> {
374        let out = self
375            .exec(
376                &format!("command -v {} >/dev/null 2>&1", shell_quote(name)),
377                None,
378                Some(15),
379            )
380            .await?;
381        Ok(out.exit_code == 0)
382    }
383}
384
385fn not_found(path: &str, e: openssh_sftp_client::Error) -> Error {
386    Error::Ssh(format!("`{path}`: {e}"))
387}
388
389/// `~` and `~/x` become `home` and `home/x`; anything else is unchanged.
390fn expand_tilde(p: &str, home: &str) -> String {
391    if p == "~" {
392        home.to_string()
393    } else if let Some(rest) = p.strip_prefix("~/") {
394        format!("{home}/{rest}")
395    } else {
396        p.to_string()
397    }
398}
399
400/// Collapse `.` and `..` segments without touching the filesystem.
401pub fn normalize(p: &str) -> String {
402    let mut parts: Vec<&str> = Vec::new();
403    for seg in p.split('/') {
404        match seg {
405            "" | "." => {}
406            ".." => {
407                parts.pop();
408            }
409            s => parts.push(s),
410        }
411    }
412    format!("/{}", parts.join("/"))
413}
414
415struct Collected {
416    data: Vec<u8>,
417    truncated: bool,
418}
419
420/// Read a stream to its end, keeping at most the first two thirds and the
421/// last third of `cap` bytes so a runaway command cannot exhaust memory.
422async fn collect_bounded<R: AsyncReadExt + Unpin>(mut r: R, cap: usize) -> Result<Collected> {
423    let head_cap = cap * 2 / 3;
424    let tail_cap = cap - head_cap;
425    let mut head = Vec::new();
426    let mut tail = Vec::new();
427    let mut truncated = false;
428    let mut buf = vec![0u8; 64 * 1024];
429    loop {
430        let n = r.read(&mut buf).await?;
431        if n == 0 {
432            break;
433        }
434        let mut chunk = &buf[..n];
435        if head.len() < head_cap {
436            let take = chunk.len().min(head_cap - head.len());
437            head.extend_from_slice(&chunk[..take]);
438            chunk = &chunk[take..];
439        }
440        if !chunk.is_empty() {
441            truncated = true;
442            tail.extend_from_slice(chunk);
443            if tail.len() > tail_cap * 2 {
444                let drop = tail.len() - tail_cap;
445                tail.drain(..drop);
446            }
447        }
448    }
449    if truncated {
450        if tail.len() > tail_cap {
451            let drop = tail.len() - tail_cap;
452            tail.drain(..drop);
453        }
454        let omitted_note = format!("\n\n[... output truncated; limit is {cap} bytes ...]\n\n");
455        head.extend_from_slice(omitted_note.as_bytes());
456        head.extend_from_slice(&tail);
457    }
458    Ok(Collected {
459        data: head,
460        truncated,
461    })
462}
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467
468    #[test]
469    fn tilde_expansion() {
470        assert_eq!(expand_tilde("~", "/root"), "/root");
471        assert_eq!(expand_tilde("~/a/b", "/root"), "/root/a/b");
472        assert_eq!(expand_tilde("/x", "/root"), "/x");
473        assert_eq!(expand_tilde("rel", "/root"), "rel");
474    }
475
476    #[test]
477    fn normalizes_paths() {
478        assert_eq!(normalize("/a/b/../c/./d"), "/a/c/d");
479        assert_eq!(normalize("/../x"), "/x");
480        assert_eq!(normalize("/"), "/");
481    }
482
483    #[tokio::test]
484    async fn bounded_collection_keeps_head_and_tail() {
485        let data: Vec<u8> = (0..10_000u32).map(|i| b'a' + (i % 26) as u8).collect();
486        let c = collect_bounded(&data[..], 300).await.unwrap();
487        assert!(c.truncated);
488        assert!(c.data.starts_with(b"abcdefghij"));
489        assert!(c.data.ends_with(&data[data.len() - 100..]));
490        let c = collect_bounded(&b"tiny"[..], 300).await.unwrap();
491        assert!(!c.truncated);
492        assert_eq!(c.data, b"tiny");
493    }
494}