Skip to main content

shuttle/
client.rs

1use crate::auth;
2use crate::config::HostEntry;
3use russh::client;
4use russh_keys::key::PublicKey;
5use std::net::ToSocketAddrs;
6use std::path::Path;
7use std::sync::Arc;
8use styrene_identity::signer::RootSecret;
9
10pub struct SshClient {
11    handle: client::Handle<ShuttleHandler>,
12    host_name: String,
13}
14
15impl SshClient {
16    pub async fn connect(
17        host_name: &str,
18        entry: &HostEntry,
19        root: &RootSecret,
20        known_hosts_path: &Path,
21    ) -> Result<Self, ClientError> {
22        let addr = format!("{}:{}", entry.address, entry.port);
23        let socket_addr = addr
24            .to_socket_addrs()
25            .map_err(|e| ClientError::Resolve(addr.clone(), e))?
26            .next()
27            .ok_or_else(|| {
28                ClientError::Resolve(addr.clone(), std::io::Error::other("no addresses resolved"))
29            })?;
30
31        let config = Arc::new(client::Config {
32            ..Default::default()
33        });
34
35        let handler = ShuttleHandler::new(
36            host_name.to_string(),
37            known_hosts_path.to_path_buf(),
38            entry.trust_on_first_use,
39        );
40
41        let mut handle = client::connect(config, socket_addr, handler)
42            .await
43            .map_err(ClientError::Connection)?;
44
45        let key_pair = auth::derive_key_pair(root, &entry.identity_label)
46            .map_err(|e| ClientError::Auth(e.to_string()))?;
47
48        let authenticated = handle
49            .authenticate_publickey(&entry.user, key_pair)
50            .await
51            .map_err(ClientError::Connection)?;
52
53        if !authenticated {
54            return Err(ClientError::Auth(
55                "server rejected public key".to_string(),
56            ));
57        }
58
59        tracing::info!(host = host_name, user = %entry.user, "authenticated");
60
61        Ok(Self {
62            handle,
63            host_name: host_name.to_string(),
64        })
65    }
66
67    pub async fn exec(
68        &self,
69        command: &str,
70        timeout: std::time::Duration,
71        max_output: usize,
72    ) -> Result<ExecResult, ClientError> {
73        let mut channel = self
74            .handle
75            .channel_open_session()
76            .await
77            .map_err(ClientError::Connection)?;
78
79        channel
80            .exec(true, command)
81            .await
82            .map_err(ClientError::Connection)?;
83
84        let mut stdout = Vec::new();
85        let mut stderr = Vec::new();
86        let mut exit_code: Option<u32> = None;
87        let mut stdout_truncated = false;
88        let mut stderr_truncated = false;
89
90        // Collect stdout/stderr until both Eof and ExitStatus are received,
91        // or the channel closes (None). Some servers send ExitStatus before
92        // Eof, others after. If a server sends only one, the outer timeout
93        // on line 134 is the safety net — worst case latency equals the
94        // configured timeout, not a hang.
95        let collect = async {
96            let mut eof_seen = false;
97            loop {
98                match channel.wait().await {
99                    Some(russh::ChannelMsg::Data { ref data }) => {
100                        if stdout.len() < max_output {
101                            let remaining = max_output - stdout.len();
102                            if data.len() <= remaining {
103                                stdout.extend_from_slice(data);
104                            } else {
105                                stdout.extend_from_slice(&data[..remaining]);
106                                stdout_truncated = true;
107                            }
108                        }
109                    }
110                    Some(russh::ChannelMsg::ExtendedData { ref data, ext }) => {
111                        if ext == 1 && stderr.len() < max_output {
112                            let remaining = max_output - stderr.len();
113                            if data.len() <= remaining {
114                                stderr.extend_from_slice(data);
115                            } else {
116                                stderr.extend_from_slice(&data[..remaining]);
117                                stderr_truncated = true;
118                            }
119                        }
120                    }
121                    Some(russh::ChannelMsg::ExitStatus { exit_status }) => {
122                        exit_code = Some(exit_status);
123                        if eof_seen {
124                            break;
125                        }
126                    }
127                    Some(russh::ChannelMsg::Eof) => {
128                        eof_seen = true;
129                        if exit_code.is_some() {
130                            break;
131                        }
132                    }
133                    None => break,
134                    _ => {}
135                }
136            }
137        };
138
139        tokio::time::timeout(timeout, collect)
140            .await
141            .map_err(|_| ClientError::Timeout(self.host_name.clone(), timeout))?;
142
143        Ok(ExecResult {
144            stdout,
145            stderr,
146            exit_code: exit_code.unwrap_or(255),
147            truncated: stdout_truncated || stderr_truncated,
148        })
149    }
150
151    pub async fn sftp(&self) -> Result<russh_sftp::client::SftpSession, ClientError> {
152        let channel = self
153            .handle
154            .channel_open_session()
155            .await
156            .map_err(ClientError::Connection)?;
157
158        channel
159            .request_subsystem(true, "sftp")
160            .await
161            .map_err(ClientError::Connection)?;
162
163        let sftp = russh_sftp::client::SftpSession::new(channel.into_stream())
164            .await
165            .map_err(|e| ClientError::Sftp(e.to_string()))?;
166
167        Ok(sftp)
168    }
169
170    pub async fn direct_tcpip(
171        &self,
172        remote_host: &str,
173        remote_port: u32,
174        local_host: &str,
175        local_port: u32,
176    ) -> Result<russh::Channel<client::Msg>, ClientError> {
177        self.handle
178            .channel_open_direct_tcpip(remote_host, remote_port, local_host, local_port)
179            .await
180            .map_err(ClientError::Connection)
181    }
182
183    pub fn host_name(&self) -> &str {
184        &self.host_name
185    }
186}
187
188#[derive(Debug)]
189pub struct ExecResult {
190    pub stdout: Vec<u8>,
191    pub stderr: Vec<u8>,
192    pub exit_code: u32,
193    pub truncated: bool,
194}
195
196struct ShuttleHandler {
197    host_name: String,
198    known_hosts_path: std::path::PathBuf,
199    tofu: bool,
200}
201
202impl ShuttleHandler {
203    fn new(host_name: String, known_hosts_path: std::path::PathBuf, tofu: bool) -> Self {
204        Self {
205            host_name,
206            known_hosts_path,
207            tofu,
208        }
209    }
210}
211
212#[async_trait::async_trait]
213impl client::Handler for ShuttleHandler {
214    type Error = russh::Error;
215
216    async fn check_server_key(
217        &mut self,
218        server_public_key: &PublicKey,
219    ) -> Result<bool, Self::Error> {
220        let fp = server_public_key.fingerprint();
221        let fp_str = format!("{fp}");
222        tracing::debug!(host = %self.host_name, fingerprint = %fp_str, "checking server key");
223
224        match check_known_host(&self.known_hosts_path, &self.host_name, &fp_str) {
225            KnownHostResult::Match => Ok(true),
226            KnownHostResult::Mismatch => {
227                tracing::error!(
228                    host = %self.host_name,
229                    "HOST KEY MISMATCH — possible MITM attack"
230                );
231                Ok(false)
232            }
233            KnownHostResult::Unknown => {
234                if self.tofu {
235                    tracing::warn!(
236                        host = %self.host_name,
237                        fingerprint = %fp_str,
238                        "trust-on-first-use: recording new host key"
239                    );
240                    if let Err(e) = record_host_key(&self.known_hosts_path, &self.host_name, &fp_str) {
241                        tracing::error!(
242                            host = %self.host_name,
243                            error = %e,
244                            "failed to persist host key — rejecting to prevent \
245                             silent TOFU bypass on next connection"
246                        );
247                        return Ok(false);
248                    }
249                    Ok(true)
250                } else {
251                    tracing::error!(
252                        host = %self.host_name,
253                        fingerprint = %fp_str,
254                        "unknown host key and TOFU disabled — rejecting"
255                    );
256                    Ok(false)
257                }
258            }
259        }
260    }
261}
262
263enum KnownHostResult {
264    Match,
265    Mismatch,
266    Unknown,
267}
268
269fn check_known_host(
270    path: &Path,
271    host_name: &str,
272    server_fingerprint: &str,
273) -> KnownHostResult {
274    let content = match std::fs::read_to_string(path) {
275        Ok(c) => c,
276        Err(_) => return KnownHostResult::Unknown,
277    };
278
279    for line in content.lines() {
280        let line = line.trim();
281        if line.is_empty() || line.starts_with('#') {
282            continue;
283        }
284        let mut parts = line.splitn(2, ' ');
285        let Some(name) = parts.next() else {
286            continue;
287        };
288        let Some(fp) = parts.next() else { continue };
289        if name == host_name {
290            if fp.trim() == server_fingerprint {
291                return KnownHostResult::Match;
292            } else {
293                return KnownHostResult::Mismatch;
294            }
295        }
296    }
297
298    KnownHostResult::Unknown
299}
300
301fn record_host_key(path: &Path, host_name: &str, fingerprint: &str) -> std::io::Result<()> {
302    use std::io::Write;
303
304    if let Some(parent) = path.parent() {
305        std::fs::create_dir_all(parent)?;
306    }
307
308    let mut opts = std::fs::OpenOptions::new();
309    opts.create(true).append(true);
310
311    #[cfg(unix)]
312    {
313        use std::os::unix::fs::OpenOptionsExt;
314        opts.mode(0o600);
315    }
316
317    let mut file = opts.open(path)?;
318    writeln!(file, "{host_name} {fingerprint}")?;
319    file.sync_all()?;
320    Ok(())
321}
322
323#[derive(Debug, thiserror::Error)]
324pub enum ClientError {
325    #[error("failed to resolve {0}: {1}")]
326    Resolve(String, std::io::Error),
327    #[error("connection error: {0}")]
328    Connection(russh::Error),
329    #[error("authentication failed: {0}")]
330    Auth(String),
331    #[error("command timed out on {0} after {1:?}")]
332    Timeout(String, std::time::Duration),
333    #[error("SFTP error: {0}")]
334    Sftp(String),
335}