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 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}