Skip to main content

blit_ssh/
lib.rs

1//! Embedded SSH client for blit.
2//!
3//! Provides connection pooling, ssh-agent authentication, `~/.ssh/config`
4//! parsing, and `direct-streamlocal` channel forwarding for connecting to
5//! remote blit-servers without shelling out to the system `ssh` binary.
6
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10use tokio::sync::Mutex;
11
12use russh::client;
13#[cfg(unix)]
14use russh::keys::agent;
15use russh::keys::{self, PrivateKeyWithHashAlg};
16
17// ── Error ──────────────────────────────────────────────────────────────
18
19#[derive(Debug, thiserror::Error)]
20pub enum Error {
21    #[error("ssh: {0}")]
22    Russh(#[from] russh::Error),
23    #[error("ssh key: {0}")]
24    Keys(#[from] keys::Error),
25    #[error("ssh: {0}")]
26    Io(#[from] std::io::Error),
27    #[error("ssh: {0}")]
28    Other(String),
29}
30
31// ── Shell scripts run on the remote ────────────────────────────────────
32
33/// Resolve the remote blit socket path.
34///
35/// Wrapped in `sh -c` so the POSIX script runs correctly even when the
36/// remote user's login shell is fish or another non-POSIX shell.
37const SOCK_SEARCH: &str = r#"sh -c 'if [ -n "$BLIT_SOCK" ]; then S="$BLIT_SOCK"; elif [ -n "$TMPDIR" ] && [ -S "$TMPDIR/blit.sock" ]; then S="$TMPDIR/blit.sock"; elif [ -S "/tmp/blit-$(id -un).sock" ]; then S="/tmp/blit-$(id -un).sock"; elif [ -S "/run/blit/$(id -un).sock" ]; then S="/run/blit/$(id -un).sock"; elif [ -n "$XDG_RUNTIME_DIR" ] && [ -S "$XDG_RUNTIME_DIR/blit.sock" ]; then S="$XDG_RUNTIME_DIR/blit.sock"; else S=/tmp/blit.sock; fi; echo "$S"'"#;
38
39/// Escape a string for use inside double quotes in a POSIX shell.
40/// Handles `\`, `$`, `` ` ``, and `"`.
41fn dq_escape(s: &str) -> String {
42    let mut out = String::with_capacity(s.len());
43    for ch in s.chars() {
44        match ch {
45            '\\' | '$' | '`' | '"' => {
46                out.push('\\');
47                out.push(ch);
48            }
49            _ => out.push(ch),
50        }
51    }
52    out
53}
54
55/// Install blit on the remote if missing, then start blit-server and
56/// detach it from the session.
57///
58/// Wrapped in `sh -c` so the POSIX script runs correctly even when the
59/// remote user's login shell is fish or another non-POSIX shell.  The
60/// socket path is double-quote-escaped to avoid single-quote nesting
61/// issues inside the outer `sh -c '…'` wrapper.
62fn install_and_start_script(socket_path: &str) -> String {
63    let escaped = dq_escape(socket_path);
64    format!(
65        "sh -c 'export PATH=\"$HOME/.local/bin:$PATH\"; \
66         if ! command -v blit >/dev/null 2>&1 && ! command -v blit-server >/dev/null 2>&1; then \
67           if command -v curl >/dev/null 2>&1; then BLIT_INSTALL_DIR=\"$HOME/.local/bin\" curl -sf https://install.blit.sh | sh >&2; \
68           elif command -v wget >/dev/null 2>&1; then BLIT_INSTALL_DIR=\"$HOME/.local/bin\" wget -qO- https://install.blit.sh | sh >&2; fi; \
69         fi; \
70         S=\"{escaped}\"; \
71         if [ -S \"$S\" ]; then \
72           if command -v nc >/dev/null 2>&1; then nc -z -U \"$S\" 2>/dev/null || rm -f \"$S\"; \
73           elif command -v socat >/dev/null 2>&1; then socat /dev/null \"UNIX-CONNECT:$S\" 2>/dev/null || rm -f \"$S\"; fi; \
74         fi; \
75         if ! [ -S \"$S\" ]; then \
76           if command -v blit >/dev/null 2>&1; then nohup blit server </dev/null >/dev/null 2>&1 & \
77           elif command -v blit-server >/dev/null 2>&1; then nohup blit-server </dev/null >/dev/null 2>&1 & fi; \
78         fi; \
79         echo ok'"
80    )
81}
82
83// ── SSH config resolution ──────────────────────────────────────────────
84
85/// Resolved SSH settings for a host, from `~/.ssh/config`.
86#[derive(Default)]
87struct ResolvedConfig {
88    hostname: Option<String>,
89    user: Option<String>,
90    port: Option<u16>,
91    identity_files: Vec<PathBuf>,
92    #[allow(dead_code)]
93    proxy_jump: Option<String>,
94}
95
96/// Minimal `~/.ssh/config` parser. Supports Host (with `*`/`?` globs),
97/// Hostname, User, Port, IdentityFile, and ProxyJump.
98fn resolve_ssh_config(host: &str) -> ResolvedConfig {
99    let path = match home_dir() {
100        Some(h) => h.join(".ssh").join("config"),
101        None => return ResolvedConfig::default(),
102    };
103    let text = match std::fs::read_to_string(&path) {
104        Ok(t) => t,
105        Err(_) => return ResolvedConfig::default(),
106    };
107
108    let mut result = ResolvedConfig::default();
109    let mut in_matching_block = false;
110    let mut in_global = true; // before the first Host line
111
112    for line in text.lines() {
113        let line = line.trim();
114        if line.is_empty() || line.starts_with('#') {
115            continue;
116        }
117        let (key, value) = match line.split_once(|c: char| c.is_ascii_whitespace() || c == '=') {
118            Some((k, v)) => (k.trim(), v.trim().trim_start_matches('=')),
119            None => continue,
120        };
121        let value = value.trim();
122        if key.eq_ignore_ascii_case("Host") {
123            in_global = false;
124            in_matching_block = value
125                .split_whitespace()
126                .any(|pattern| host_matches(pattern, host));
127            continue;
128        }
129        if !in_matching_block && !in_global {
130            continue;
131        }
132        if key.eq_ignore_ascii_case("Hostname") && result.hostname.is_none() {
133            result.hostname = Some(value.to_string());
134        } else if key.eq_ignore_ascii_case("User") && result.user.is_none() {
135            result.user = Some(value.to_string());
136        } else if key.eq_ignore_ascii_case("Port") && result.port.is_none() {
137            result.port = value.parse().ok();
138        } else if key.eq_ignore_ascii_case("IdentityFile") {
139            let expanded = expand_tilde(value);
140            result.identity_files.push(PathBuf::from(expanded));
141        } else if key.eq_ignore_ascii_case("ProxyJump") && result.proxy_jump.is_none() {
142            result.proxy_jump = Some(value.to_string());
143        }
144    }
145    result
146}
147
148/// Simple glob match supporting `*` (any chars) and `?` (one char).
149fn host_matches(pattern: &str, host: &str) -> bool {
150    let mut p = pattern.chars().peekable();
151    let mut h = host.chars().peekable();
152    host_matches_inner(&mut p, &mut h)
153}
154
155fn host_matches_inner(
156    p: &mut std::iter::Peekable<std::str::Chars>,
157    h: &mut std::iter::Peekable<std::str::Chars>,
158) -> bool {
159    while let Some(&pc) = p.peek() {
160        match pc {
161            '*' => {
162                p.next();
163                if p.peek().is_none() {
164                    return true; // trailing * matches everything
165                }
166                // Try matching * against 0..N chars of h
167                loop {
168                    let mut p2 = p.clone();
169                    let mut h2 = h.clone();
170                    if host_matches_inner(&mut p2, &mut h2) {
171                        return true;
172                    }
173                    if h.next().is_none() {
174                        return false;
175                    }
176                }
177            }
178            '?' => {
179                p.next();
180                if h.next().is_none() {
181                    return false;
182                }
183            }
184            _ => {
185                p.next();
186                match h.next() {
187                    Some(hc) if hc == pc => {}
188                    _ => return false,
189                }
190            }
191        }
192    }
193    h.peek().is_none()
194}
195
196fn expand_tilde(path: &str) -> String {
197    if let Some(rest) = path.strip_prefix("~/")
198        && let Some(home) = home_dir()
199    {
200        return format!("{}/{rest}", home.display());
201    }
202    path.to_string()
203}
204
205// ── Handler ────────────────────────────────────────────────────────────
206
207struct SshHandler {
208    host: String,
209    port: u16,
210}
211
212impl client::Handler for SshHandler {
213    type Error = Error;
214
215    async fn check_server_key(
216        &mut self,
217        server_public_key: &keys::PublicKey,
218    ) -> Result<bool, Self::Error> {
219        let known_hosts_path = match home_dir() {
220            Some(h) => h.join(".ssh").join("known_hosts"),
221            None => return Ok(true), // No home dir — accept
222        };
223        if !known_hosts_path.exists() {
224            // No known_hosts file — accept-new behaviour: create file and
225            // record the key.
226            if let Some(parent) = known_hosts_path.parent() {
227                let _ = std::fs::create_dir_all(parent);
228            }
229            append_known_host(&known_hosts_path, &self.host, self.port, server_public_key);
230            return Ok(true);
231        }
232        match keys::check_known_hosts_path(
233            &self.host,
234            self.port,
235            server_public_key,
236            &known_hosts_path,
237        ) {
238            Ok(true) => Ok(true),
239            Ok(false) => {
240                // Key not in file — accept-new: append and accept.
241                append_known_host(&known_hosts_path, &self.host, self.port, server_public_key);
242                Ok(true)
243            }
244            Err(keys::Error::KeyChanged { .. }) => Err(Error::Other(format!(
245                "host key for {}:{} has changed! \
246                     This could indicate a man-in-the-middle attack. \
247                     Remove the old key from ~/.ssh/known_hosts to continue.",
248                self.host, self.port
249            ))),
250            Err(_) => {
251                // Other errors (parse failure, etc.) — accept-new.
252                append_known_host(&known_hosts_path, &self.host, self.port, server_public_key);
253                Ok(true)
254            }
255        }
256    }
257}
258
259fn append_known_host(path: &Path, host: &str, port: u16, key: &keys::PublicKey) {
260    use keys::PublicKeyBase64;
261    let host_entry = if port == 22 {
262        host.to_string()
263    } else {
264        format!("[{host}]:{port}")
265    };
266    let algo = key.algorithm().to_string();
267    let b64 = key.public_key_base64();
268    let line = format!("{host_entry} {algo} {b64}\n");
269    let _ = std::fs::OpenOptions::new()
270        .create(true)
271        .append(true)
272        .open(path)
273        .and_then(|mut f| {
274            use std::io::Write;
275            f.write_all(line.as_bytes())
276        });
277}
278
279// ── SSH Pool ───────────────────────────────────────────────────────────
280
281/// SSH connection pool. Maintains persistent SSH connections and opens
282/// channels on demand. Multiple channels share a single TCP+SSH connection
283/// per host. Thread-safe and cheaply cloneable via `Arc`.
284#[derive(Clone)]
285pub struct SshPool {
286    inner: Arc<PoolInner>,
287}
288
289struct PoolInner {
290    /// Cached connections keyed by `"user@host:port"`.
291    connections: Mutex<HashMap<String, CachedConnection>>,
292}
293
294struct CachedConnection {
295    handle: client::Handle<SshHandler>,
296    /// Resolved remote blit socket path (cached after first resolution).
297    remote_socket: Option<String>,
298}
299
300impl Default for SshPool {
301    fn default() -> Self {
302        Self::new()
303    }
304}
305
306impl SshPool {
307    pub fn new() -> Self {
308        Self {
309            inner: Arc::new(PoolInner {
310                connections: Mutex::new(HashMap::new()),
311            }),
312        }
313    }
314
315    /// Open a `direct-streamlocal` channel to a remote blit-server.
316    ///
317    /// - Resolves `~/.ssh/config` for the target host.
318    /// - Reuses an existing SSH connection if available.
319    /// - Authenticates via ssh-agent, then falls back to key files.
320    /// - If `remote_socket` is `None`, discovers the socket path on the remote.
321    /// - Auto-starts blit-server on the remote if needed.
322    /// - Returns a bidirectional `DuplexStream` connected to the remote socket.
323    pub async fn connect(
324        &self,
325        host: &str,
326        user: Option<&str>,
327        remote_socket: Option<&str>,
328    ) -> Result<tokio::io::DuplexStream, Error> {
329        let config = resolve_ssh_config(host);
330        let effective_host = config.hostname.as_deref().unwrap_or(host);
331        let effective_user = user
332            .map(String::from)
333            .or(config.user.clone())
334            .unwrap_or_else(current_username);
335        let effective_port = config.port.unwrap_or(22);
336
337        let key = format!("{effective_user}@{effective_host}:{effective_port}");
338
339        let mut conns = self.inner.connections.lock().await;
340
341        // Try reusing an existing connection.
342        let need_new = match conns.get(&key) {
343            Some(cached) => cached.handle.is_closed(),
344            None => true,
345        };
346
347        if need_new {
348            let handle =
349                establish_connection(effective_host, effective_port, &effective_user, &config)
350                    .await?;
351            conns.insert(
352                key.clone(),
353                CachedConnection {
354                    handle,
355                    remote_socket: None,
356                },
357            );
358        }
359
360        let cached = conns.get_mut(&key).unwrap();
361
362        // Resolve remote socket path if not cached and not explicitly provided.
363        let socket_path = if let Some(explicit) = remote_socket {
364            explicit.to_string()
365        } else if let Some(ref cached_path) = cached.remote_socket {
366            cached_path.clone()
367        } else {
368            let path = exec_command(&cached.handle, SOCK_SEARCH).await?;
369            let path = path.trim().to_string();
370            if path.is_empty() {
371                return Err(Error::Other(
372                    "could not determine remote blit socket path".into(),
373                ));
374            }
375            cached.remote_socket = Some(path.clone());
376            path
377        };
378
379        // Try to open the channel. If it fails, install + start and retry.
380        let channel = match cached
381            .handle
382            .channel_open_direct_streamlocal(&socket_path)
383            .await
384        {
385            Ok(ch) => ch,
386            Err(_first_err) => {
387                // Install blit if missing and (re)start the server.
388                let _ = exec_command(&cached.handle, &install_and_start_script(&socket_path)).await;
389                // Retry with back-off: the server needs a moment to create
390                // the socket after starting.
391                let mut last_err = _first_err;
392                for attempt in 0..10 {
393                    tokio::time::sleep(std::time::Duration::from_millis(100 * (attempt + 1))).await;
394                    match cached
395                        .handle
396                        .channel_open_direct_streamlocal(&socket_path)
397                        .await
398                    {
399                        Ok(ch) => return Ok(bridge_channel(ch)),
400                        Err(e) => last_err = e,
401                    }
402                }
403                return Err(Error::Other(format!(
404                    "failed to connect to {socket_path} after install: {last_err}"
405                )));
406            }
407        };
408
409        Ok(bridge_channel(channel))
410    }
411}
412
413/// Bridge an SSH channel to a `DuplexStream` so callers get a standard
414/// tokio type with no russh types leaking.
415fn bridge_channel(channel: russh::Channel<russh::client::Msg>) -> tokio::io::DuplexStream {
416    let stream = channel.into_stream();
417    let (client, server) = tokio::io::duplex(64 * 1024);
418    tokio::spawn(async move {
419        let (mut sr, mut sw) = tokio::io::split(server);
420        let (mut cr, mut cw) = tokio::io::split(stream);
421        tokio::select! {
422            _ = tokio::io::copy(&mut cr, &mut sw) => {}
423            _ = tokio::io::copy(&mut sr, &mut cw) => {}
424        }
425    });
426    client
427}
428
429// ── Connection + Authentication ────────────────────────────────────────
430
431async fn establish_connection(
432    host: &str,
433    port: u16,
434    user: &str,
435    config: &ResolvedConfig,
436) -> Result<client::Handle<SshHandler>, Error> {
437    let ssh_config = client::Config {
438        ..Default::default()
439    };
440
441    let handler = SshHandler {
442        host: host.to_string(),
443        port,
444    };
445
446    let mut handle = client::connect(Arc::new(ssh_config), (host, port), handler).await?;
447
448    // Try ssh-agent first.
449    if try_agent_auth(&mut handle, user).await {
450        return Ok(handle);
451    }
452
453    // Fall back to key files.
454    if try_key_file_auth(&mut handle, user, config).await? {
455        return Ok(handle);
456    }
457
458    Err(Error::Other(format!(
459        "authentication failed for {user}@{host}:{port} \
460         (tried ssh-agent and key files)"
461    )))
462}
463
464/// Try authenticating via ssh-agent. Returns true on success.
465#[cfg(unix)]
466async fn try_agent_auth(handle: &mut client::Handle<SshHandler>, user: &str) -> bool {
467    let agent_path = match std::env::var("SSH_AUTH_SOCK") {
468        Ok(p) if !p.is_empty() => p,
469        _ => return false,
470    };
471    let stream = match tokio::net::UnixStream::connect(&agent_path).await {
472        Ok(s) => s,
473        Err(e) => {
474            log::debug!("ssh-agent connect failed: {e}");
475            return false;
476        }
477    };
478    let mut agent = agent::client::AgentClient::connect(stream);
479    let identities = match agent.request_identities().await {
480        Ok(ids) => ids,
481        Err(e) => {
482            log::debug!("ssh-agent request_identities failed: {e}");
483            return false;
484        }
485    };
486    for identity in &identities {
487        let public_key = identity.public_key().into_owned();
488        match handle
489            .authenticate_publickey_with(user, public_key, None, &mut agent)
490            .await
491        {
492            Ok(russh::client::AuthResult::Success) => return true,
493            Ok(_) => continue,
494            Err(e) => {
495                log::debug!("ssh-agent auth attempt failed: {e}");
496                continue;
497            }
498        }
499    }
500    false
501}
502
503/// On non-Unix platforms, agent auth is not yet supported — fall back to key files.
504#[cfg(not(unix))]
505async fn try_agent_auth(_handle: &mut client::Handle<SshHandler>, _user: &str) -> bool {
506    false
507}
508
509/// Try authenticating with key files. Returns true on success.
510async fn try_key_file_auth(
511    handle: &mut client::Handle<SshHandler>,
512    user: &str,
513    config: &ResolvedConfig,
514) -> Result<bool, Error> {
515    let home = match home_dir() {
516        Some(h) => h,
517        None => return Ok(false),
518    };
519
520    // Collect candidate key paths: explicit from config + defaults.
521    let mut candidates: Vec<PathBuf> = config.identity_files.clone();
522    for default in &["id_ed25519", "id_ecdsa", "id_rsa"] {
523        let p = home.join(".ssh").join(default);
524        if !candidates.contains(&p) {
525            candidates.push(p);
526        }
527    }
528
529    for path in &candidates {
530        if !path.exists() {
531            continue;
532        }
533        let key = match keys::load_secret_key(path, None) {
534            Ok(k) => k,
535            Err(e) => {
536                log::debug!("could not load {}: {e}", path.display());
537                continue;
538            }
539        };
540
541        // Determine the best RSA hash algorithm if applicable.
542        let hash_alg = handle.best_supported_rsa_hash().await.ok().flatten();
543        let key_with_hash = PrivateKeyWithHashAlg::new(Arc::new(key), hash_alg.flatten());
544
545        match handle.authenticate_publickey(user, key_with_hash).await {
546            Ok(russh::client::AuthResult::Success) => return Ok(true),
547            Ok(_) => continue,
548            Err(e) => {
549                log::debug!("key auth failed for {}: {e}", path.display());
550                continue;
551            }
552        }
553    }
554    Ok(false)
555}
556
557// ── Remote command execution ───────────────────────────────────────────
558
559/// Execute a command on the remote and return its stdout.
560async fn exec_command(handle: &client::Handle<SshHandler>, cmd: &str) -> Result<String, Error> {
561    let mut channel = handle.channel_open_session().await?;
562    channel.exec(true, cmd.as_bytes()).await?;
563
564    let mut output = Vec::new();
565    while let Some(msg) = channel.wait().await {
566        match msg {
567            russh::ChannelMsg::Data { data } => output.extend_from_slice(&data),
568            russh::ChannelMsg::Eof | russh::ChannelMsg::Close => break,
569            _ => continue,
570        }
571    }
572    Ok(String::from_utf8_lossy(&output).into_owned())
573}
574
575// ── Helpers ────────────────────────────────────────────────────────────
576
577fn home_dir() -> Option<PathBuf> {
578    #[cfg(unix)]
579    {
580        std::env::var("HOME").ok().map(PathBuf::from)
581    }
582    #[cfg(windows)]
583    {
584        std::env::var("USERPROFILE").ok().map(PathBuf::from)
585    }
586}
587
588fn current_username() -> String {
589    #[cfg(unix)]
590    {
591        std::env::var("USER").unwrap_or_else(|_| "root".into())
592    }
593    #[cfg(windows)]
594    {
595        std::env::var("USERNAME").unwrap_or_else(|_| "user".into())
596    }
597}
598
599/// Parse an SSH URI: `[user@]host[:/socket]`.
600/// Returns `(user, host, socket)`.
601pub fn parse_ssh_uri(s: &str) -> (Option<String>, String, Option<String>) {
602    let colon_start = s.find('@').map(|a| a + 1).unwrap_or(0);
603    let (host_part, socket) = if let Some(rel) = s[colon_start..].find(':') {
604        let pos = colon_start + rel;
605        let path = &s[pos + 1..];
606        if path.is_empty() {
607            (s, None)
608        } else {
609            (&s[..pos], Some(path.to_string()))
610        }
611    } else {
612        (s, None)
613    };
614    let (user, host) = if let Some(at) = host_part.rfind('@') {
615        (
616            Some(host_part[..at].to_string()),
617            host_part[at + 1..].to_string(),
618        )
619    } else {
620        (None, host_part.to_string())
621    };
622    (user, host, socket)
623}