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}