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}