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}