Skip to main content

toride_ssh_agent/
lib.rs

1//! SSH agent management (listing, adding, removing keys).
2
3pub mod askpass;
4mod client;
5mod session;
6
7pub use askpass::AskpassHandler;
8pub use client::list_identities;
9pub use session::ControlSession;
10
11use std::path::Path;
12
13use toride_ssh_core::Error;
14use toride_ssh_core::Result;
15use toride_ssh_core::SshKey;
16use toride_ssh_core::SshPaths;
17
18/// SSH agent operations.
19///
20/// Obtained from `SshManager::agent()`.
21pub struct AgentService<'a> {
22    paths: &'a SshPaths,
23    runner: &'a dyn toride_ssh_core::CliRunner,
24}
25
26impl<'a> AgentService<'a> {
27    pub fn new(paths: &'a SshPaths, runner: &'a dyn toride_ssh_core::CliRunner) -> Self {
28        Self { paths, runner }
29    }
30
31    /// Check if the SSH agent is reachable.
32    ///
33    /// Returns `true` when `SSH_AUTH_SOCK` points to an existing socket and
34    /// we can successfully connect to the agent.
35    ///
36    /// # Errors
37    ///
38    /// Returns an error if the agent check command itself fails unexpectedly
39    /// (not merely because the agent is unavailable).
40    pub async fn status(&self) -> Result<bool> {
41        #[cfg(feature = "native")]
42        {
43            // `connect()` already validates `SSH_AUTH_SOCK` and socket
44            // existence, so no need to duplicate those checks here.
45            match client::connect().await {
46                Ok(mut c) => {
47                    c.request_identities()
48                        .await
49                        .map_err(|e| Error::AgentOperationFailed(e.to_string()))?;
50                    Ok(true)
51                }
52                Err(Error::AgentNotAvailable) => Ok(false),
53                Err(e) => Err(e),
54            }
55        }
56
57        #[cfg(not(feature = "native"))]
58        {
59            match self.runner.run("ssh-add", vec!["-l".to_owned()]).await {
60                Ok(_) => Ok(true),
61                Err(Error::CommandFailed(_)) => Ok(false),
62                Err(e) => Err(e),
63            }
64        }
65    }
66
67    /// List all keys currently loaded in the SSH agent.
68    ///
69    /// # Errors
70    ///
71    /// Returns [`Error::AgentNotAvailable`] if the agent is not running,
72    /// or [`Error::AgentOperationFailed`] if the agent protocol fails.
73    pub async fn list_keys(&self) -> Result<Vec<SshKey>> {
74        client::list_identities(self.runner).await
75    }
76
77    /// Add a private key to the SSH agent.
78    ///
79    /// # Errors
80    ///
81    /// Returns [`Error::AgentOperationFailed`] if the key cannot be added
82    /// (e.g. passphrase required, agent rejects the key).
83    pub async fn add_key(&self, key_path: &Path) -> Result<()> {
84        client::add_key(key_path, self.runner).await
85    }
86
87    /// Remove a key from the SSH agent.
88    ///
89    /// # Errors
90    ///
91    /// Returns [`Error::AgentOperationFailed`] if the key cannot be removed.
92    pub async fn remove_key(&self, key_path: &Path) -> Result<()> {
93        client::remove_key(key_path, self.runner).await
94    }
95
96    /// Test whether a key is usable by the SSH agent (`ssh-add -T`).
97    ///
98    /// Returns `true` if the key is usable (already decrypted/loaded or
99    /// accessible via hardware token), `false` otherwise.
100    ///
101    /// # Errors
102    ///
103    /// Returns [`Error::AgentOperationFailed`] if the test command itself
104    /// cannot be executed.
105    pub async fn test_key_usability(&self, key_path: &Path) -> Result<bool> {
106        client::test_key_usability(key_path, self.runner).await
107    }
108
109    /// Add a key restricted to specific destination hosts (`ssh-add -h`).
110    ///
111    /// The key will only be authorized for connections to the listed hosts.
112    /// At least one host must be provided.
113    ///
114    /// # Errors
115    ///
116    /// Returns [`Error::AgentOperationFailed`] if `hosts` is empty or if
117    /// the command fails.
118    pub async fn destination_constrained_add(&self, key_path: &Path, hosts: &[&str]) -> Result<()> {
119        client::destination_constrained_add(key_path, hosts, self.runner).await
120    }
121
122    /// Remove all keys from the SSH agent (`ssh-add -D`).
123    ///
124    /// Returns the number of keys removed (best-effort; some agents don't report count).
125    ///
126    /// # Errors
127    ///
128    /// Returns [`Error::CommandFailed`] if the command fails.
129    pub async fn remove_all(&self) -> Result<()> {
130        let result = self.runner.run("ssh-add", vec!["-D".to_owned()]).await?;
131
132        tracing::debug!("ssh-add -D output: {result}");
133        Ok(())
134    }
135
136    /// Add a key to the SSH agent with a lifetime limit (`ssh-add -t`).
137    ///
138    /// `lifetime_seconds` specifies how long the key should remain loaded.
139    ///
140    /// # Errors
141    ///
142    /// Returns [`Error::CommandFailed`] if the key cannot be added or the
143    /// lifetime argument is invalid.
144    pub async fn add_key_with_lifetime(
145        &self,
146        key_path: &Path,
147        lifetime_seconds: u32,
148    ) -> Result<()> {
149        let path_str = key_path
150            .to_str()
151            .ok_or_else(|| Error::CommandFailed("key path is not valid UTF-8".to_owned()))?
152            .to_owned();
153
154        self.runner
155            .run(
156                "ssh-add",
157                vec!["-t".to_owned(), lifetime_seconds.to_string(), path_str],
158            )
159            .await?;
160        Ok(())
161    }
162
163    /// Add a key to the SSH agent with confirmation required (`ssh-add -c`).
164    ///
165    /// The agent will request user confirmation each time the key is used.
166    ///
167    /// # Errors
168    ///
169    /// Returns [`Error::CommandFailed`] if the key cannot be added.
170    pub async fn add_key_with_confirmation(&self, key_path: &Path) -> Result<()> {
171        let path_str = key_path
172            .to_str()
173            .ok_or_else(|| Error::CommandFailed("key path is not valid UTF-8".to_owned()))?
174            .to_owned();
175
176        self.runner
177            .run("ssh-add", vec!["-c".to_owned(), path_str])
178            .await?;
179        Ok(())
180    }
181
182    /// Add a passphrase-protected key to the SSH agent using `SSH_ASKPASS`.
183    ///
184    /// This creates a temporary askpass script that supplies the passphrase
185    /// non-interactively, which is useful when loading keys from a TUI or
186    /// other automated context where terminal input is not available.
187    ///
188    /// The temporary script is cleaned up automatically when this method
189    /// returns (on success or failure).
190    ///
191    /// # Errors
192    ///
193    /// Returns [`Error::CommandFailed`] if the key cannot be added, or
194    /// [`Error::Io`] if the temporary askpass script cannot be created.
195    pub async fn add_key_with_passphrase(&self, key_path: &Path, passphrase: &str) -> Result<()> {
196        let askpass = AskpassHandler::new(passphrase)?;
197        let path_str = key_path
198            .to_str()
199            .ok_or_else(|| Error::CommandFailed("key path is not valid UTF-8".to_owned()))?
200            .to_owned();
201        let script = askpass.script_path().to_string_lossy().into_owned();
202
203        let result = self
204            .runner
205            .run_with_env(
206                "ssh-add",
207                vec![path_str],
208                vec![
209                    ("SSH_ASKPASS".to_owned(), script),
210                    ("SSH_ASKPASS_REQUIRE".to_owned(), "force".to_owned()),
211                    ("DISPLAY".to_owned(), ":0".to_owned()),
212                ],
213            )
214            .await;
215
216        // askpass is dropped here, cleaning up the temporary script.
217        result?;
218        Ok(())
219    }
220
221    /// Add a key to the SSH agent and store its passphrase in the macOS Keychain
222    /// (`ssh-add --apple-use-keychain <key>`).
223    ///
224    /// This integrates with the macOS Keychain so the key's passphrase is
225    /// remembered across reboots. Only available on macOS.
226    ///
227    /// # Errors
228    ///
229    /// Returns [`Error::CommandFailed`] if the key cannot be added to the
230    /// keychain.
231    #[cfg(target_os = "macos")]
232    pub async fn add_to_keychain(&self, key_path: &Path) -> Result<()> {
233        let path_str = key_path
234            .to_str()
235            .ok_or_else(|| Error::CommandFailed("key path is not valid UTF-8".to_owned()))?
236            .to_owned();
237
238        self.runner
239            .run("ssh-add", vec!["--apple-use-keychain".to_owned(), path_str])
240            .await?;
241        Ok(())
242    }
243
244    /// Load all keys from the macOS Keychain into the SSH agent
245    /// (`ssh-add --apple-load-keychain`).
246    ///
247    /// Reads stored passphrases from the Keychain and loads the corresponding
248    /// keys automatically. Only available on macOS.
249    ///
250    /// # Errors
251    ///
252    /// Returns [`Error::CommandFailed`] if the keychain load fails.
253    #[cfg(target_os = "macos")]
254    pub async fn load_keychain(&self) -> Result<()> {
255        self.runner
256            .run("ssh-add", vec!["--apple-load-keychain".to_owned()])
257            .await?;
258        Ok(())
259    }
260
261    /// List active `ControlMaster` sessions.
262    ///
263    /// Scans for control socket files in the SSH directory and `/tmp`,
264    /// verifying each is still alive.
265    ///
266    /// # Errors
267    ///
268    /// Returns [`Error::TaskFailed`] if the background scan task panics
269    /// or is cancelled.
270    pub async fn list_sessions(&self) -> Result<Vec<ControlSession>> {
271        session::list_sessions(self.paths.ssh_dir()).await
272    }
273}