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