Skip to main content

auths_core/agent/
handle.rs

1//! Agent handle for lifecycle management.
2//!
3//! This module provides `AgentHandle`, a wrapper around `AgentCore` that enables
4//! proper lifecycle management (start/stop/restart) for the SSH agent daemon.
5
6use crate::agent::AgentCore;
7use crate::error::AgentError;
8use std::path::PathBuf;
9use std::sync::atomic::{AtomicBool, Ordering};
10use std::sync::{Arc, Mutex, MutexGuard};
11use std::time::{Duration, Instant};
12use zeroize::Zeroizing;
13
14/// Default idle timeout (30 minutes)
15pub const DEFAULT_IDLE_TIMEOUT: Duration = Duration::from_secs(30 * 60);
16
17/// A handle to an agent instance that manages its lifecycle.
18///
19/// `AgentHandle` wraps an `AgentCore` and provides:
20/// - Socket path and PID file tracking
21/// - Lifecycle management (shutdown, status checks)
22/// - Thread-safe access to the underlying `AgentCore`
23/// - Idle timeout and key locking
24///
25/// Unlike the previous global static pattern, multiple `AgentHandle` instances
26/// can coexist, enabling proper testing and multi-agent scenarios.
27pub struct AgentHandle {
28    /// The underlying agent core wrapped in a mutex for thread-safe access
29    core: Arc<Mutex<AgentCore>>,
30    /// Path to the Unix domain socket
31    socket_path: PathBuf,
32    /// Path to the PID file (optional)
33    pid_file: Option<PathBuf>,
34    /// Whether the agent is currently running
35    running: Arc<AtomicBool>,
36    /// Timestamp of last activity (for idle timeout, shared across clones)
37    last_activity: Arc<Mutex<Instant>>,
38    /// Idle timeout duration (0 = never timeout)
39    idle_timeout: Duration,
40    /// Whether the agent is currently locked (shared across clones)
41    locked: Arc<AtomicBool>,
42}
43
44impl std::fmt::Debug for AgentHandle {
45    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46        f.debug_struct("AgentHandle")
47            .field("socket_path", &self.socket_path)
48            .field("pid_file", &self.pid_file)
49            .field("running", &self.is_running())
50            .field("locked", &self.is_agent_locked())
51            .field("idle_timeout", &self.idle_timeout)
52            .finish_non_exhaustive()
53    }
54}
55
56impl AgentHandle {
57    /// Creates a new agent handle with the specified socket path.
58    pub fn new(socket_path: PathBuf) -> Self {
59        Self::with_timeout(socket_path, DEFAULT_IDLE_TIMEOUT)
60    }
61
62    /// Creates a new agent handle with the specified socket path and timeout.
63    pub fn with_timeout(socket_path: PathBuf, idle_timeout: Duration) -> Self {
64        Self {
65            core: Arc::new(Mutex::new(AgentCore::default())),
66            socket_path,
67            pid_file: None,
68            running: Arc::new(AtomicBool::new(false)),
69            last_activity: Arc::new(Mutex::new(Instant::now())),
70            idle_timeout,
71            locked: Arc::new(AtomicBool::new(false)),
72        }
73    }
74
75    /// Creates a new agent handle with socket and PID file paths.
76    pub fn with_pid_file(socket_path: PathBuf, pid_file: PathBuf) -> Self {
77        Self {
78            core: Arc::new(Mutex::new(AgentCore::default())),
79            socket_path,
80            pid_file: Some(pid_file),
81            running: Arc::new(AtomicBool::new(false)),
82            last_activity: Arc::new(Mutex::new(Instant::now())),
83            idle_timeout: DEFAULT_IDLE_TIMEOUT,
84            locked: Arc::new(AtomicBool::new(false)),
85        }
86    }
87
88    /// Creates a new agent handle with socket, PID file, and custom timeout.
89    pub fn with_pid_file_and_timeout(
90        socket_path: PathBuf,
91        pid_file: PathBuf,
92        idle_timeout: Duration,
93    ) -> Self {
94        Self {
95            core: Arc::new(Mutex::new(AgentCore::default())),
96            socket_path,
97            pid_file: Some(pid_file),
98            running: Arc::new(AtomicBool::new(false)),
99            last_activity: Arc::new(Mutex::new(Instant::now())),
100            idle_timeout,
101            locked: Arc::new(AtomicBool::new(false)),
102        }
103    }
104
105    /// Creates an agent handle from an existing `AgentCore`.
106    pub fn from_core(core: AgentCore, socket_path: PathBuf) -> Self {
107        Self {
108            core: Arc::new(Mutex::new(core)),
109            socket_path,
110            pid_file: None,
111            running: Arc::new(AtomicBool::new(false)),
112            last_activity: Arc::new(Mutex::new(Instant::now())),
113            idle_timeout: DEFAULT_IDLE_TIMEOUT,
114            locked: Arc::new(AtomicBool::new(false)),
115        }
116    }
117
118    /// Returns the socket path for this agent.
119    pub fn socket_path(&self) -> &PathBuf {
120        &self.socket_path
121    }
122
123    /// Returns the PID file path, if configured.
124    pub fn pid_file(&self) -> Option<&PathBuf> {
125        self.pid_file.as_ref()
126    }
127
128    /// Sets the PID file path.
129    pub fn set_pid_file(&mut self, path: PathBuf) {
130        self.pid_file = Some(path);
131    }
132
133    /// Acquires a lock on the agent core.
134    ///
135    /// # Errors
136    /// Returns `AgentError::MutexError` if the mutex is poisoned.
137    pub fn lock(&self) -> Result<MutexGuard<'_, AgentCore>, AgentError> {
138        self.core
139            .lock()
140            .map_err(|_| AgentError::MutexError("Agent core mutex poisoned".to_string()))
141    }
142
143    /// Returns a clone of the inner `Arc<Mutex<AgentCore>>` for sharing.
144    pub fn core_arc(&self) -> Arc<Mutex<AgentCore>> {
145        Arc::clone(&self.core)
146    }
147
148    /// Returns whether the agent is currently running.
149    pub fn is_running(&self) -> bool {
150        self.running.load(Ordering::SeqCst)
151    }
152
153    /// Marks the agent as running.
154    pub fn set_running(&self, running: bool) {
155        self.running.store(running, Ordering::SeqCst);
156    }
157
158    // --- Idle Timeout and Locking ---
159
160    /// Returns the configured idle timeout duration.
161    pub fn idle_timeout(&self) -> Duration {
162        self.idle_timeout
163    }
164
165    /// Records activity, resetting the idle timer.
166    pub fn touch(&self) {
167        if let Ok(mut last) = self.last_activity.lock() {
168            *last = Instant::now();
169        }
170    }
171
172    /// Returns the duration since the last activity.
173    pub fn idle_duration(&self) -> Duration {
174        self.last_activity
175            .lock()
176            .map(|last| last.elapsed())
177            .unwrap_or(Duration::ZERO)
178    }
179
180    /// Returns whether the agent has exceeded the idle timeout.
181    pub fn is_idle_timed_out(&self) -> bool {
182        // A timeout of 0 means never timeout
183        if self.idle_timeout.is_zero() {
184            return false;
185        }
186        self.idle_duration() >= self.idle_timeout
187    }
188
189    /// Returns whether the agent is currently locked.
190    pub fn is_agent_locked(&self) -> bool {
191        self.locked.load(Ordering::SeqCst)
192    }
193
194    /// Locks the agent, clearing all keys from memory.
195    ///
196    /// After locking, sign operations will fail with `AgentError::AgentLocked`.
197    pub fn lock_agent(&self) -> Result<(), AgentError> {
198        log::info!("Locking agent (clearing keys from memory)");
199
200        // Clear all keys (zeroizes sensitive data)
201        {
202            let mut core = self.lock()?;
203            core.clear_keys();
204        }
205
206        // Mark as locked
207        self.locked.store(true, Ordering::SeqCst);
208        log::debug!("Agent locked");
209        Ok(())
210    }
211
212    /// Unlocks the agent (marks as unlocked).
213    ///
214    /// Note: This only clears the locked flag. Keys must be re-loaded separately
215    /// using `register_key` or the CLI `auths agent unlock` command.
216    pub fn unlock_agent(&self) {
217        log::info!("Unlocking agent");
218        self.locked.store(false, Ordering::SeqCst);
219        self.touch(); // Reset idle timer
220    }
221
222    /// Checks idle timeout and locks the agent if exceeded.
223    ///
224    /// Call this periodically from a background task.
225    pub fn check_idle_timeout(&self) -> Result<bool, AgentError> {
226        if self.is_idle_timed_out() && !self.is_agent_locked() {
227            log::info!(
228                "Agent idle for {:?}, locking due to timeout",
229                self.idle_duration()
230            );
231            self.lock_agent()?;
232            return Ok(true);
233        }
234        Ok(false)
235    }
236
237    /// Shuts down the agent, clearing all keys and resources.
238    ///
239    /// This method:
240    /// 1. Clears all keys from the agent core (zeroizing sensitive data)
241    /// 2. Marks the agent as not running
242    /// 3. Optionally removes the socket file and PID file
243    #[allow(clippy::disallowed_methods)] // INVARIANT: daemon lifecycle — socket/PID cleanup is inherently I/O
244    pub fn shutdown(&self) -> Result<(), AgentError> {
245        log::info!("Shutting down agent at {:?}", self.socket_path);
246
247        // Clear all keys (zeroizes sensitive data)
248        {
249            let mut core = self.lock()?;
250            core.clear_keys();
251            log::debug!("Cleared all keys from agent core");
252        }
253
254        // Mark as not running
255        self.set_running(false);
256
257        // Remove socket file if it exists
258        if self.socket_path.exists() {
259            if let Err(e) = std::fs::remove_file(&self.socket_path) {
260                log::warn!("Failed to remove socket file {:?}: {}", self.socket_path, e);
261            } else {
262                log::debug!("Removed socket file {:?}", self.socket_path);
263            }
264        }
265
266        // Remove PID file if it exists
267        if let Some(ref pid_file) = self.pid_file
268            && pid_file.exists()
269        {
270            if let Err(e) = std::fs::remove_file(pid_file) {
271                log::warn!("Failed to remove PID file {:?}: {}", pid_file, e);
272            } else {
273                log::debug!("Removed PID file {:?}", pid_file);
274            }
275        }
276
277        log::info!("Agent shutdown complete");
278        Ok(())
279    }
280
281    /// Returns the number of keys currently loaded in the agent.
282    pub fn key_count(&self) -> Result<usize, AgentError> {
283        let core = self.lock()?;
284        Ok(core.key_count())
285    }
286
287    /// Returns all public key bytes currently registered.
288    pub fn public_keys(&self) -> Result<Vec<Vec<u8>>, AgentError> {
289        let core = self.lock()?;
290        Ok(core.public_keys())
291    }
292
293    /// Registers a key in the agent core.
294    pub fn register_key(&self, pkcs8_bytes: Zeroizing<Vec<u8>>) -> Result<(), AgentError> {
295        let mut core = self.lock()?;
296        core.register_key(pkcs8_bytes)
297    }
298
299    /// Signs data using a key in the agent core.
300    ///
301    /// # Errors
302    /// Returns `AgentError::AgentLocked` if the agent is locked.
303    pub fn sign(&self, pubkey: &[u8], data: &[u8]) -> Result<Vec<u8>, AgentError> {
304        // Check if agent is locked
305        if self.is_agent_locked() {
306            return Err(AgentError::AgentLocked);
307        }
308
309        let core = self.lock()?;
310        let result = core.sign(pubkey, data);
311
312        // Touch on successful sign to reset idle timer
313        if result.is_ok() {
314            self.touch();
315        }
316
317        result
318    }
319}
320
321impl Clone for AgentHandle {
322    fn clone(&self) -> Self {
323        Self {
324            core: Arc::clone(&self.core),
325            socket_path: self.socket_path.clone(),
326            pid_file: self.pid_file.clone(),
327            running: Arc::clone(&self.running),
328            last_activity: Arc::clone(&self.last_activity),
329            idle_timeout: self.idle_timeout,
330            locked: Arc::clone(&self.locked),
331        }
332    }
333}
334
335#[cfg(test)]
336#[allow(clippy::disallowed_methods)]
337mod tests {
338    use super::*;
339    use ring::rand::SystemRandom;
340    use ring::signature::Ed25519KeyPair;
341    use tempfile::TempDir;
342
343    fn generate_test_pkcs8() -> Vec<u8> {
344        let rng = SystemRandom::new();
345        let pkcs8_doc = Ed25519KeyPair::generate_pkcs8(&rng).expect("Failed to generate PKCS#8");
346        pkcs8_doc.as_ref().to_vec()
347    }
348
349    #[test]
350    fn test_agent_handle_new() {
351        let handle = AgentHandle::new(PathBuf::from("/tmp/test.sock"));
352        assert_eq!(handle.socket_path(), &PathBuf::from("/tmp/test.sock"));
353        assert!(handle.pid_file().is_none());
354        assert!(!handle.is_running());
355    }
356
357    #[test]
358    fn test_agent_handle_with_pid_file() {
359        let handle = AgentHandle::with_pid_file(
360            PathBuf::from("/tmp/test.sock"),
361            PathBuf::from("/tmp/test.pid"),
362        );
363        assert_eq!(handle.socket_path(), &PathBuf::from("/tmp/test.sock"));
364        assert_eq!(handle.pid_file(), Some(&PathBuf::from("/tmp/test.pid")));
365    }
366
367    #[test]
368    fn test_agent_handle_running_state() {
369        let handle = AgentHandle::new(PathBuf::from("/tmp/test.sock"));
370        assert!(!handle.is_running());
371
372        handle.set_running(true);
373        assert!(handle.is_running());
374
375        handle.set_running(false);
376        assert!(!handle.is_running());
377    }
378
379    #[test]
380    fn test_agent_handle_key_operations() {
381        let handle = AgentHandle::new(PathBuf::from("/tmp/test.sock"));
382
383        assert_eq!(handle.key_count().unwrap(), 0);
384
385        let pkcs8_bytes = generate_test_pkcs8();
386        handle
387            .register_key(Zeroizing::new(pkcs8_bytes))
388            .expect("Failed to register key");
389
390        assert_eq!(handle.key_count().unwrap(), 1);
391
392        let pubkeys = handle.public_keys().unwrap();
393        assert_eq!(pubkeys.len(), 1);
394    }
395
396    #[test]
397    fn test_agent_handle_clone_shares_state() {
398        let handle1 = AgentHandle::new(PathBuf::from("/tmp/test.sock"));
399        let handle2 = handle1.clone();
400
401        let pkcs8_bytes = generate_test_pkcs8();
402        handle1
403            .register_key(Zeroizing::new(pkcs8_bytes))
404            .expect("Failed to register key");
405
406        // Both handles should see the same key
407        assert_eq!(handle1.key_count().unwrap(), 1);
408        assert_eq!(handle2.key_count().unwrap(), 1);
409    }
410
411    #[test]
412    fn test_agent_handle_shutdown() {
413        let temp_dir = TempDir::new().unwrap();
414        let socket_path = temp_dir.path().join("test.sock");
415
416        // Create a dummy socket file
417        std::fs::write(&socket_path, "dummy").unwrap();
418
419        let handle = AgentHandle::new(socket_path.clone());
420        let pkcs8_bytes = generate_test_pkcs8();
421        handle
422            .register_key(Zeroizing::new(pkcs8_bytes))
423            .expect("Failed to register key");
424        handle.set_running(true);
425
426        assert_eq!(handle.key_count().unwrap(), 1);
427        assert!(handle.is_running());
428        assert!(socket_path.exists());
429
430        handle.shutdown().expect("Shutdown failed");
431
432        assert_eq!(handle.key_count().unwrap(), 0);
433        assert!(!handle.is_running());
434        assert!(!socket_path.exists());
435    }
436
437    #[test]
438    fn test_multiple_handles_independent() {
439        let handle1 = AgentHandle::new(PathBuf::from("/tmp/agent1.sock"));
440        let handle2 = AgentHandle::new(PathBuf::from("/tmp/agent2.sock"));
441
442        let pkcs8_bytes = generate_test_pkcs8();
443        handle1
444            .register_key(Zeroizing::new(pkcs8_bytes))
445            .expect("Failed to register key");
446
447        // Handles are independent - handle2 should have no keys
448        assert_eq!(handle1.key_count().unwrap(), 1);
449        assert_eq!(handle2.key_count().unwrap(), 0);
450    }
451
452    #[test]
453    fn test_agent_handle_lock_unlock() {
454        let handle = AgentHandle::new(PathBuf::from("/tmp/test.sock"));
455
456        // Initially not locked
457        assert!(!handle.is_agent_locked());
458
459        // Add a key
460        let pkcs8_bytes = generate_test_pkcs8();
461        handle
462            .register_key(Zeroizing::new(pkcs8_bytes))
463            .expect("Failed to register key");
464        assert_eq!(handle.key_count().unwrap(), 1);
465
466        // Lock the agent
467        handle.lock_agent().expect("Lock failed");
468        assert!(handle.is_agent_locked());
469        assert_eq!(handle.key_count().unwrap(), 0); // Keys cleared
470
471        // Unlock the agent
472        handle.unlock_agent();
473        assert!(!handle.is_agent_locked());
474    }
475
476    #[test]
477    fn test_agent_handle_sign_when_locked() {
478        let handle = AgentHandle::new(PathBuf::from("/tmp/test.sock"));
479
480        // Add a key
481        let pkcs8_bytes = generate_test_pkcs8();
482        handle
483            .register_key(Zeroizing::new(pkcs8_bytes))
484            .expect("Failed to register key");
485
486        // Get pubkey for signing
487        let pubkeys = handle.public_keys().unwrap();
488        let pubkey = &pubkeys[0];
489
490        // Sign should work when not locked
491        let result = handle.sign(pubkey, b"test data");
492        assert!(result.is_ok());
493
494        // Lock and try to sign
495        handle.lock_agent().expect("Lock failed");
496        let result = handle.sign(pubkey, b"test data");
497        assert!(matches!(result, Err(AgentError::AgentLocked)));
498    }
499
500    #[test]
501    fn test_agent_handle_idle_timeout() {
502        // Create handle with very short timeout for testing
503        let handle =
504            AgentHandle::with_timeout(PathBuf::from("/tmp/test.sock"), Duration::from_millis(10));
505
506        // Initially not timed out
507        assert!(!handle.is_idle_timed_out());
508        assert!(!handle.is_agent_locked());
509
510        // Wait for timeout
511        std::thread::sleep(Duration::from_millis(20));
512
513        // Should be timed out now
514        assert!(handle.is_idle_timed_out());
515
516        // Touch resets the timer
517        handle.touch();
518        assert!(!handle.is_idle_timed_out());
519    }
520
521    #[test]
522    fn test_agent_handle_zero_timeout_never_expires() {
523        // Create handle with zero timeout (never expires)
524        let handle = AgentHandle::with_timeout(PathBuf::from("/tmp/test.sock"), Duration::ZERO);
525
526        // Wait a bit
527        std::thread::sleep(Duration::from_millis(10));
528
529        // Should never be timed out
530        assert!(!handle.is_idle_timed_out());
531    }
532
533    #[test]
534    fn test_clone_shares_locked_state() {
535        let handle_a = AgentHandle::new(PathBuf::from("/tmp/test.sock"));
536        let handle_b = handle_a.clone();
537
538        assert!(!handle_b.is_agent_locked());
539        handle_a.lock_agent().unwrap();
540        assert!(handle_b.is_agent_locked());
541
542        handle_a.unlock_agent();
543        assert!(!handle_b.is_agent_locked());
544    }
545
546    #[test]
547    fn test_clone_shares_last_activity() {
548        let handle_a =
549            AgentHandle::with_timeout(PathBuf::from("/tmp/test.sock"), Duration::from_millis(50));
550        let handle_b = handle_a.clone();
551
552        std::thread::sleep(Duration::from_millis(60));
553        assert!(handle_b.is_idle_timed_out());
554
555        // Touch on clone A resets timer visible from clone B
556        handle_a.touch();
557        assert!(!handle_b.is_idle_timed_out());
558    }
559
560    #[test]
561    fn test_clone_sign_returns_locked_after_other_clone_locks() {
562        let handle_a = AgentHandle::new(PathBuf::from("/tmp/test.sock"));
563        let handle_b = handle_a.clone();
564
565        let pkcs8_bytes = generate_test_pkcs8();
566        handle_a.register_key(Zeroizing::new(pkcs8_bytes)).unwrap();
567
568        let pubkeys = handle_a.public_keys().unwrap();
569        let pubkey = &pubkeys[0];
570
571        assert!(handle_b.sign(pubkey, b"test data").is_ok());
572
573        handle_a.lock_agent().unwrap();
574        let result = handle_b.sign(pubkey, b"test data");
575        assert!(matches!(result, Err(AgentError::AgentLocked)));
576    }
577}