1use super::{
6 take_utf8_capped, TunnelChannel, SshClientTrait, ConnectionConfig, ExecutionOutput,
7 TransferResult,
8 };
9 use crate::errors::{SshCliError, SshCliResult};
10 use async_trait::async_trait;
11 use std::path::Path;
12 use std::time::{Duration, Instant};
13 use zeroize::Zeroizing;
14
15 pub use crate::ssh::client_handler::ClientHandler;
17
18 pub struct SshClient {
20 pub session: russh::client::Handle<ClientHandler>,
22 cfg: ConnectionConfig,
23 }
24
25 impl std::fmt::Debug for SshClient {
26 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27 f.debug_struct("SshClient")
28 .field("host", &self.cfg.host)
29 .field("port", &self.cfg.port)
30 .field("user", &self.cfg.username)
31 .field("timeout_ms", &self.cfg.timeout_ms)
32 .finish()
33 }
34 }
35
36 impl SshClient {
37 #[must_use]
39 pub fn timeout_ms(&self) -> u64 {
40 self.cfg.timeout_ms.get()
41 }
42 }
43
44 fn map_exit_status(exit_status: u32) -> i32 {
45 i32::try_from(exit_status).unwrap_or(-1)
46 }
47
48 fn process_exec_message(
49 msg: russh::ChannelMsg,
50 stdout_bytes: &mut Vec<u8>,
51 stderr_bytes: &mut Vec<u8>,
52 exit_code: &mut Option<i32>,
53 byte_cap: usize,
54 truncated_stdout: &mut bool,
55 truncated_stderr: &mut bool,
56 ) -> bool {
57 use russh::ChannelMsg;
58
59 match msg {
60 ChannelMsg::Data { data } => {
61 super::append_capped(stdout_bytes, data.as_ref(), byte_cap, truncated_stdout);
63 }
64 ChannelMsg::ExtendedData { data, ext } => {
65 if ext == 1 {
67 super::append_capped(stderr_bytes, data.as_ref(), byte_cap, truncated_stderr);
68 } else {
69 tracing::debug!(ext, "extended data ignored");
70 }
71 }
72 ChannelMsg::ExitStatus { exit_status } => {
73 *exit_code = Some(map_exit_status(exit_status));
77 }
79 ChannelMsg::ExitSignal {
80 signal_name,
81 core_dumped,
82 error_message,
83 ..
84 } => {
85 tracing::warn!(
86 ?signal_name,
87 core_dumped,
88 %error_message,
89 "remote process terminated by signal"
90 );
91 }
93 ChannelMsg::Eof => {
94 tracing::debug!("EOF on SSH channel");
95 }
96 ChannelMsg::Close => {
97 tracing::debug!("SSH channel closed by server");
98 return true;
99 }
100 _ => {}
101 }
102
103 false
104 }
105
106 use crate::ssh::scp_wire::{
111 apply_local_mode, format_scp_t_line, format_scp_upload_header_with_mode,
112 interpret_scp_status, parse_scp_header, parse_scp_t_line, partial_download_path,
113 remote_scp_command, scp_mode_from_metadata, scp_read_data, scp_read_until_newline,
114 scp_wait_status, system_time_secs, SCP_OK,
115 };
116
117 impl SshClient {
118 pub async fn connect(cfg: ConnectionConfig) -> SshCliResult<Self> {
129 let auth = crate::ssh::client_connect::connect_authenticated(cfg).await?;
130 Ok(Self {
131 session: auth.session,
132 cfg: auth.cfg,
133 })
134 }
135
136 pub async fn run_command(
138 &mut self,
139 command: &str,
140 max_chars: usize,
141 stdin_data: Option<Vec<u8>>,
142 ) -> SshCliResult<ExecutionOutput> {
143 self.run_command_internal(command, max_chars, true, stdin_data)
144 .await
145 }
146
147 async fn run_command_internal(
148 &mut self,
149 command: &str,
150 max_chars: usize,
151 abort_on_timeout: bool,
152 stdin_data: Option<Vec<u8>>,
153 ) -> SshCliResult<ExecutionOutput> {
154 let start = Instant::now();
155 let timeout = Duration::from_millis(self.cfg.timeout_ms.get());
156
157 let stdin_data: Option<Zeroizing<Vec<u8>>> = stdin_data.map(Zeroizing::new);
162 let result = tokio::time::timeout(timeout, async {
163 let mut channel = self
164 .session
165 .channel_open_session()
166 .await
167 .map_err(|e| SshCliError::channel_msg(format!("open session: {e}")))?;
168
169 channel
170 .exec(true, command)
171 .await
172 .map_err(|e| SshCliError::channel_msg(format!("exec: {e}")))?;
173
174 if let Some(ref bytes) = stdin_data {
176 channel
177 .data(bytes.as_slice())
178 .await
179 .map_err(|e| SshCliError::channel_msg(format!("stdin channel: {e}")))?;
180 channel
181 .eof()
182 .await
183 .map_err(|e| SshCliError::channel_msg(format!("eof channel: {e}")))?;
184 }
185 drop(stdin_data);
187
188 let byte_cap = super::exec_capture_byte_cap(max_chars);
190 let initial = byte_cap.min(8 * 1024);
191 let mut stdout_bytes: Vec<u8> = Vec::with_capacity(initial);
192 let mut stderr_bytes: Vec<u8> = Vec::with_capacity(initial);
193 let mut exit_code: Option<i32> = None;
194 let mut byte_trunc_stdout = false;
195 let mut byte_trunc_stderr = false;
196
197 while let Some(msg) = channel.wait().await {
198 if crate::signals::should_stop() {
200 return Err(SshCliError::Config(
201 "operation cancelled by signal".to_string(),
202 ));
203 }
204 if process_exec_message(
205 msg,
206 &mut stdout_bytes,
207 &mut stderr_bytes,
208 &mut exit_code,
209 byte_cap,
210 &mut byte_trunc_stdout,
211 &mut byte_trunc_stderr,
212 ) {
213 break;
214 }
215 }
216
217 Ok::<_, SshCliError>((
218 stdout_bytes,
219 stderr_bytes,
220 exit_code,
221 byte_trunc_stdout,
222 byte_trunc_stderr,
223 ))
224 })
225 .await;
226
227 let (stdout_bytes, stderr_bytes, exit_code, byte_trunc_stdout, byte_trunc_stderr) =
228 match result {
229 Ok(Ok(t)) => t,
230 Ok(Err(err)) => return Err(err),
231 Err(_) => {
232 if abort_on_timeout {
233 if let Some(pattern) = crate::ssh::packing::remote_abort_pattern(command)
234 {
235 let abort_cmd = crate::ssh::packing::pack_abort_pkill(&pattern);
236 tracing::warn!(
237 pattern = %pattern,
238 "local timeout; attempting best-effort remote abort"
239 );
240 let _ = self.try_remote_abort(&abort_cmd).await;
241 }
242 }
243 return Err(SshCliError::SshTimeout(self.cfg.timeout_ms.get()));
244 }
245 };
246
247 let (stdout_truncado, trunc_stdout_chars) =
249 take_utf8_capped(stdout_bytes, max_chars);
250 let (stderr_truncado, trunc_stderr_chars) =
251 take_utf8_capped(stderr_bytes, max_chars);
252 let truncated_stdout = trunc_stdout_chars || byte_trunc_stdout;
253 let truncated_stderr = trunc_stderr_chars || byte_trunc_stderr;
254
255 let duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
256
257 Ok(ExecutionOutput {
258 stdout: stdout_truncado,
259 stderr: stderr_truncado,
260 exit_code,
261 truncated_stdout,
262 truncated_stderr,
263 duration_ms,
264 })
265 }
266 }