Skip to main content

kovra_agent/
daemon.rs

1//! Socket lifecycle for the governed ssh-agent (KOV-13, decision Q4:
2//! foreground-only MVP; decision Q5: refuse-and-guide on a pre-existing
3//! `$SSH_AUTH_SOCK`).
4//!
5//! This is the OS edge: it binds a UNIX socket (mode `0600`), prints the
6//! `SSH_AUTH_SOCK` to export, and serves connections in the foreground until
7//! Ctrl-C, removing the socket on exit. **The socket peer and a real `ssh`
8//! client are `[host]`** — validated on hardware by the human, not asserted by
9//! automated tests (CLAUDE.md rule 4). The *protocol* and *session* logic it
10//! drives ([`crate::protocol`], [`crate::session`]) are fully mock-tested.
11
12use std::io::Write;
13use std::os::unix::net::{UnixListener, UnixStream};
14use std::path::{Path, PathBuf};
15
16use crate::error::AgentError;
17use crate::protocol::{encode_failure, frame, parse_request, read_frame};
18use crate::session::Session;
19
20/// Refuse to start if `$SSH_AUTH_SOCK` is already set — we never hijack or chain
21/// an existing agent (decision Q5). The caller prints the guidance carried by
22/// [`AgentError::AuthSockAlreadySet`].
23pub fn ensure_no_existing_agent() -> Result<(), AgentError> {
24    if let Some(sock) = std::env::var_os("SSH_AUTH_SOCK") {
25        return Err(AgentError::AuthSockAlreadySet(
26            sock.to_string_lossy().into_owned(),
27        ));
28    }
29    Ok(())
30}
31
32/// Bind the agent socket at `path` (mode `0600`), removing a stale socket file
33/// first. Returns the listener; the caller serves it with [`serve`].
34pub fn bind(path: &Path) -> Result<UnixListener, AgentError> {
35    // Remove a leftover socket from a previous run (a path that exists but has
36    // no live listener). We only ever remove a socket file, never a regular file.
37    if path.exists() {
38        let is_socket = std::fs::symlink_metadata(path)
39            .map(|m| {
40                use std::os::unix::fs::FileTypeExt;
41                m.file_type().is_socket()
42            })
43            .unwrap_or(false);
44        if is_socket {
45            let _ = std::fs::remove_file(path);
46        } else {
47            return Err(AgentError::Socket(format!(
48                "{} exists and is not a socket — refusing to overwrite",
49                path.display()
50            )));
51        }
52    }
53    let listener = UnixListener::bind(path)
54        .map_err(|e| AgentError::Socket(format!("bind {}: {e}", path.display())))?;
55    #[cfg(unix)]
56    {
57        use std::os::unix::fs::PermissionsExt;
58        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
59            .map_err(|e| AgentError::Socket(format!("chmod {}: {e}", path.display())))?;
60    }
61    Ok(listener)
62}
63
64/// A reasonable default socket path under the vault root: `<root>/agent.sock`.
65/// (The vault root is already `0700`, so the socket inherits a private parent.)
66pub fn default_socket_path(root: &Path) -> PathBuf {
67    root.join("agent.sock")
68}
69
70/// Serve the agent in the **foreground** until the listener is closed or an
71/// unrecoverable error occurs. Each accepted connection is handled to completion
72/// (one request → one reply, looping until the peer closes). Per-connection
73/// errors are isolated: a malformed frame answers `SSH_AGENT_FAILURE` and the
74/// connection continues; a transport error drops just that connection.
75///
76/// `make_session` is called per request to build a fresh [`Session`] view over
77/// the (possibly re-read) custodied keys — so a key added/removed while the
78/// daemon runs is reflected, and the confirmer/audit are the live ones.
79pub fn serve<F>(listener: &UnixListener, mut make_session: F) -> Result<(), AgentError>
80where
81    F: FnMut() -> Result<SessionOwned, AgentError>,
82{
83    for incoming in listener.incoming() {
84        match incoming {
85            Ok(stream) => {
86                if let Err(e) = handle_connection(stream, &mut make_session) {
87                    // Log to stderr and keep serving — one bad peer must not take
88                    // the daemon down. No key bytes are ever in `e`.
89                    eprintln!("kovra ssh-agent: connection error: {e}");
90                }
91            }
92            Err(e) => {
93                eprintln!("kovra ssh-agent: accept error: {e}");
94            }
95        }
96    }
97    Ok(())
98}
99
100/// Owned session inputs, so `serve`'s closure can build a session per request
101/// without lifetime entanglement with the listener loop.
102pub struct SessionOwned {
103    /// The custodied keys (with private halves).
104    pub keys: Vec<crate::session::KeypairEntry>,
105    /// The agent scope.
106    pub scope: kovra_core::AgentScope,
107    /// The confirmer.
108    pub confirmer: Box<dyn kovra_core::Confirmer>,
109    /// The audit sink.
110    pub audit: Box<dyn kovra_core::AuditSink>,
111    /// The clock.
112    pub clock: Box<dyn kovra_core::Clock>,
113    /// The confirmation timeout.
114    pub confirm_timeout: std::time::Duration,
115    /// The observed requesting process (I16).
116    pub requesting_process: Option<String>,
117}
118
119impl SessionOwned {
120    fn as_session(&self) -> Session<'_> {
121        Session {
122            keys: &self.keys,
123            scope: &self.scope,
124            confirmer: self.confirmer.as_ref(),
125            audit: self.audit.as_ref(),
126            clock: self.clock.as_ref(),
127            confirm_timeout: self.confirm_timeout,
128            requesting_process: self.requesting_process.clone(),
129        }
130    }
131}
132
133fn handle_connection<F>(mut stream: UnixStream, make_session: &mut F) -> Result<(), AgentError>
134where
135    F: FnMut() -> Result<SessionOwned, AgentError>,
136{
137    loop {
138        let body = match read_frame(&mut stream)? {
139            Some(b) => b,
140            None => return Ok(()), // peer closed at a frame boundary
141        };
142        let reply_body = match parse_request(&body) {
143            Ok(request) => {
144                let owned = make_session()?;
145                let session = owned.as_session();
146                session.handle(&request)?
147            }
148            // A malformed/unknown frame is answered with FAILURE, not a close —
149            // matches the fuzz-target contract (never panic, always a valid reply).
150            Err(_) => encode_failure(),
151        };
152        stream.write_all(&frame(&reply_body))?;
153        stream.flush()?;
154    }
155}
156
157/// Remove the socket file on shutdown (best-effort). Idempotent.
158pub fn cleanup(path: &Path) {
159    let _ = std::fs::remove_file(path);
160}