lmrc_ssh/client.rs
1//! SSH client implementation.
2
3use crate::{CommandOutput, Error, Result};
4use ssh2::Session;
5use std::io::Read;
6use std::net::TcpStream;
7use std::path::Path;
8
9/// Authentication methods for SSH connections.
10#[derive(Debug, Clone)]
11pub enum AuthMethod {
12 /// Authenticate using a username and password.
13 Password {
14 /// The username for authentication.
15 username: String,
16 /// The password for authentication.
17 password: String,
18 },
19
20 /// Authenticate using a public/private key pair.
21 PublicKey {
22 /// The username for authentication.
23 username: String,
24 /// Path to the private key file.
25 private_key_path: String,
26 /// Optional passphrase for the private key.
27 passphrase: Option<String>,
28 },
29}
30
31/// An SSH client for executing remote commands.
32///
33/// # Examples
34///
35/// ```rust,no_run
36/// use lmrc_ssh::{SshClient, AuthMethod};
37///
38/// # fn main() -> Result<(), lmrc_ssh::Error> {
39/// let mut client = SshClient::new("example.com", 22)?
40/// .with_auth(AuthMethod::Password {
41/// username: "user".to_string(),
42/// password: "pass".to_string(),
43/// })
44/// .connect()?;
45///
46/// let output = client.execute("hostname")?;
47/// println!("Hostname: {}", output.stdout);
48/// # Ok(())
49/// # }
50/// ```
51pub struct SshClient {
52 host: String,
53 port: u16,
54 auth: Option<AuthMethod>,
55 session: Option<Session>,
56}
57
58impl std::fmt::Debug for SshClient {
59 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60 f.debug_struct("SshClient")
61 .field("host", &self.host)
62 .field("port", &self.port)
63 .field("auth", &self.auth)
64 .field("connected", &self.is_connected())
65 .finish()
66 }
67}
68
69impl SshClient {
70 /// Creates a new SSH client instance.
71 ///
72 /// # Arguments
73 ///
74 /// * `host` - The hostname or IP address of the remote server
75 /// * `port` - The SSH port (typically 22)
76 ///
77 /// # Examples
78 ///
79 /// ```rust
80 /// use lmrc_ssh::SshClient;
81 ///
82 /// # fn main() -> Result<(), lmrc_ssh::Error> {
83 /// let client = SshClient::new("example.com", 22)?;
84 /// # Ok(())
85 /// # }
86 /// ```
87 pub fn new(host: impl Into<String>, port: u16) -> Result<Self> {
88 let host = host.into();
89
90 if host.is_empty() {
91 return Err(Error::InvalidConfig("Host cannot be empty".to_string()));
92 }
93
94 if port == 0 {
95 return Err(Error::InvalidConfig("Port cannot be 0".to_string()));
96 }
97
98 Ok(Self {
99 host,
100 port,
101 auth: None,
102 session: None,
103 })
104 }
105
106 /// Sets the authentication method for the connection.
107 ///
108 /// # Arguments
109 ///
110 /// * `auth` - The authentication method to use
111 ///
112 /// # Examples
113 ///
114 /// ```rust
115 /// use lmrc_ssh::{SshClient, AuthMethod};
116 ///
117 /// # fn main() -> Result<(), lmrc_ssh::Error> {
118 /// let client = SshClient::new("example.com", 22)?
119 /// .with_auth(AuthMethod::Password {
120 /// username: "user".to_string(),
121 /// password: "pass".to_string(),
122 /// });
123 /// # Ok(())
124 /// # }
125 /// ```
126 pub fn with_auth(mut self, auth: AuthMethod) -> Self {
127 self.auth = Some(auth);
128 self
129 }
130
131 /// Establishes the SSH connection and authenticates.
132 ///
133 /// # Errors
134 ///
135 /// Returns an error if:
136 /// - The TCP connection fails
137 /// - The SSH handshake fails
138 /// - Authentication fails
139 /// - No authentication method was set
140 ///
141 /// # Examples
142 ///
143 /// ```rust,no_run
144 /// use lmrc_ssh::{SshClient, AuthMethod};
145 ///
146 /// # fn main() -> Result<(), lmrc_ssh::Error> {
147 /// let mut client = SshClient::new("example.com", 22)?
148 /// .with_auth(AuthMethod::Password {
149 /// username: "user".to_string(),
150 /// password: "pass".to_string(),
151 /// })
152 /// .connect()?;
153 /// # Ok(())
154 /// # }
155 /// ```
156 pub fn connect(mut self) -> Result<Self> {
157 let auth = self
158 .auth
159 .as_ref()
160 .ok_or_else(|| Error::InvalidConfig("No authentication method set".to_string()))?;
161
162 let tcp = TcpStream::connect(format!("{}:{}", self.host, self.port)).map_err(|e| {
163 Error::ConnectionFailed {
164 host: self.host.clone(),
165 port: self.port,
166 source: e,
167 }
168 })?;
169
170 let mut session = Session::new()?;
171 session.set_tcp_stream(tcp);
172 session.handshake()?;
173
174 match auth {
175 AuthMethod::Password { username, password } => {
176 session.userauth_password(username, password).map_err(|e| {
177 Error::AuthenticationFailed {
178 username: username.clone(),
179 reason: e.to_string(),
180 }
181 })?;
182 }
183 AuthMethod::PublicKey {
184 username,
185 private_key_path,
186 passphrase,
187 } => {
188 let key_path = Path::new(private_key_path);
189
190 if !key_path.exists() {
191 return Err(Error::PrivateKeyNotFound {
192 path: private_key_path.clone(),
193 });
194 }
195
196 session
197 .userauth_pubkey_file(username, None, key_path, passphrase.as_deref())
198 .map_err(|e| Error::AuthenticationFailed {
199 username: username.clone(),
200 reason: e.to_string(),
201 })?;
202 }
203 }
204
205 if !session.authenticated() {
206 let username = match auth {
207 AuthMethod::Password { username, .. } => username.clone(),
208 AuthMethod::PublicKey { username, .. } => username.clone(),
209 };
210 return Err(Error::AuthenticationFailed {
211 username,
212 reason: "Authentication completed but session is not authenticated".to_string(),
213 });
214 }
215
216 self.session = Some(session);
217 Ok(self)
218 }
219
220 /// Executes a command on the remote server.
221 ///
222 /// # Arguments
223 ///
224 /// * `command` - The command to execute
225 ///
226 /// # Errors
227 ///
228 /// Returns an error if:
229 /// - The client is not connected
230 /// - Failed to open an SSH channel
231 /// - Failed to execute the command
232 ///
233 /// # Examples
234 ///
235 /// ```rust,no_run
236 /// use lmrc_ssh::{SshClient, AuthMethod};
237 ///
238 /// # fn main() -> Result<(), lmrc_ssh::Error> {
239 /// let mut client = SshClient::new("example.com", 22)?
240 /// .with_auth(AuthMethod::Password {
241 /// username: "user".to_string(),
242 /// password: "pass".to_string(),
243 /// })
244 /// .connect()?;
245 ///
246 /// let output = client.execute("ls -la /tmp")?;
247 /// println!("Files:\n{}", output.stdout);
248 /// # Ok(())
249 /// # }
250 /// ```
251 pub fn execute(&mut self, command: &str) -> Result<CommandOutput> {
252 let session = self.session.as_ref().ok_or(Error::NotConnected)?;
253
254 let mut channel = session
255 .channel_session()
256 .map_err(|e| Error::ChannelFailed(e.to_string()))?;
257
258 channel
259 .exec(command)
260 .map_err(|e| Error::ExecutionFailed(e.to_string()))?;
261
262 let mut stdout = String::new();
263 channel
264 .read_to_string(&mut stdout)
265 .map_err(|e| Error::ExecutionFailed(format!("Failed to read stdout: {}", e)))?;
266
267 let mut stderr = String::new();
268 channel
269 .stderr()
270 .read_to_string(&mut stderr)
271 .map_err(|e| Error::ExecutionFailed(format!("Failed to read stderr: {}", e)))?;
272
273 channel
274 .wait_close()
275 .map_err(|e| Error::ExecutionFailed(format!("Failed to close channel: {}", e)))?;
276
277 let exit_status = channel.exit_status()?;
278
279 Ok(CommandOutput::new(stdout, stderr, exit_status))
280 }
281
282 /// Executes multiple commands sequentially.
283 ///
284 /// # Arguments
285 ///
286 /// * `commands` - A slice of commands to execute
287 ///
288 /// # Returns
289 ///
290 /// Returns a vector of `CommandOutput` for each executed command.
291 ///
292 /// # Examples
293 ///
294 /// ```rust,no_run
295 /// use lmrc_ssh::{SshClient, AuthMethod};
296 ///
297 /// # fn main() -> Result<(), lmrc_ssh::Error> {
298 /// let mut client = SshClient::new("example.com", 22)?
299 /// .with_auth(AuthMethod::Password {
300 /// username: "user".to_string(),
301 /// password: "pass".to_string(),
302 /// })
303 /// .connect()?;
304 ///
305 /// let outputs = client.execute_batch(&["whoami", "hostname", "pwd"])?;
306 /// for output in outputs {
307 /// println!("{}", output.stdout);
308 /// }
309 /// # Ok(())
310 /// # }
311 /// ```
312 pub fn execute_batch(&mut self, commands: &[&str]) -> Result<Vec<CommandOutput>> {
313 commands.iter().map(|cmd| self.execute(cmd)).collect()
314 }
315
316 /// Returns the hostname this client is configured to connect to.
317 pub fn host(&self) -> &str {
318 &self.host
319 }
320
321 /// Returns the port this client is configured to connect to.
322 pub fn port(&self) -> u16 {
323 self.port
324 }
325
326 /// Returns whether the client is currently connected.
327 pub fn is_connected(&self) -> bool {
328 self.session.is_some()
329 }
330}
331
332#[cfg(test)]
333mod tests {
334 use super::*;
335
336 #[test]
337 fn test_new_client() {
338 let client = SshClient::new("example.com", 22).unwrap();
339 assert_eq!(client.host(), "example.com");
340 assert_eq!(client.port(), 22);
341 assert!(!client.is_connected());
342 }
343
344 #[test]
345 fn test_empty_host() {
346 let result = SshClient::new("", 22);
347 assert!(result.is_err());
348 assert!(matches!(result.unwrap_err(), Error::InvalidConfig(_)));
349 }
350
351 #[test]
352 fn test_zero_port() {
353 let result = SshClient::new("example.com", 0);
354 assert!(result.is_err());
355 assert!(matches!(result.unwrap_err(), Error::InvalidConfig(_)));
356 }
357
358 #[test]
359 fn test_with_auth() {
360 let client = SshClient::new("example.com", 22)
361 .unwrap()
362 .with_auth(AuthMethod::Password {
363 username: "user".to_string(),
364 password: "pass".to_string(),
365 });
366 assert!(client.auth.is_some());
367 }
368
369 #[test]
370 fn test_connect_without_auth() {
371 let client = SshClient::new("example.com", 22).unwrap();
372 let result = client.connect();
373 assert!(result.is_err());
374 assert!(matches!(result.unwrap_err(), Error::InvalidConfig(_)));
375 }
376}