leviath_runtime/control_socket/unix.rs
1//! Unix-domain-socket transport for the control channel.
2//!
3//! A [`ControlId`] is a filesystem path; the socket is never reachable off the
4//! machine, and access to it is governed by ordinary file permissions - which
5//! [`bind_control_listener`] must set explicitly, never assume. An un-`chmod`ed
6//! socket and directory land at the process umask (typically 0755, and
7//! group-writable under a 0002 umask), and anyone who can connect can spawn a
8//! tool-executing agent and answer its approval prompts.
9
10use std::path::{Path, PathBuf};
11
12/// Identifies a control socket: the filesystem path of the Unix-domain socket.
13pub type ControlId = PathBuf;
14
15/// The client end of a control connection.
16pub type ClientStream = tokio::net::UnixStream;
17
18/// The server end of an accepted control connection.
19pub type ServerStream = tokio::net::UnixStream;
20
21/// The control socket path under `base` (e.g. `<leviath-home>/.leviath`).
22pub fn control_id(base: &Path) -> ControlId {
23 base.join("control.sock")
24}
25
26/// Parse a user-supplied `--socket` override into a [`ControlId`] (a path).
27pub fn control_id_from_str(s: &str) -> ControlId {
28 PathBuf::from(s)
29}
30
31/// True if a daemon is currently answering on the socket at `id`.
32pub fn is_daemon_running(id: &Path) -> bool {
33 std::os::unix::net::UnixStream::connect(id).is_ok()
34}
35
36/// A bound control listener wrapping a Unix-domain socket.
37#[derive(Debug)]
38pub struct ControlListener(tokio::net::UnixListener);
39
40impl ControlListener {
41 /// Accept the next control connection **from this user**.
42 ///
43 /// A connection from another uid is closed and skipped rather than returned,
44 /// so the daemon loop above never sees it and no `handle_connection` runs
45 /// for it. This uid check is the channel's authentication, and it cannot be
46 /// skipped: the ops this channel accepts spawn tool-executing agents and
47 /// answer their approval prompts.
48 ///
49 /// The socket's 0600 mode is not sufficient on its own: on macOS and the
50 /// BSDs, permissions on a Unix socket are not consulted at `connect` time.
51 /// The peer's uid comes from the kernel, so it is the check that actually
52 /// holds on every platform.
53 ///
54 /// A peer whose uid cannot be determined is refused. An unidentifiable
55 /// caller is not an authorized one, and failing open here would undo the
56 /// whole point on any platform where the lookup is unavailable.
57 pub async fn accept(&mut self) -> std::io::Result<Option<ServerStream>> {
58 self.accept_with(leviath_sys::peer_uid).await
59 }
60
61 /// Core of [`accept`](Self::accept) with the peer lookup injected.
62 ///
63 /// A `fn` pointer (not `impl Fn`) so there is one monomorphization. The seam
64 /// exists because the refusal arms cannot be reached otherwise: producing a
65 /// connection from a *different* uid needs a second user account or root,
66 /// which no CI runner has. Injecting the lookup lets both refusals - a
67 /// foreign uid and an undeterminable one - be driven against a real socket.
68 async fn accept_with(
69 &mut self,
70 peer_uid: fn(&ServerStream) -> Option<u32>,
71 ) -> std::io::Result<Option<ServerStream>> {
72 // One expression, no `?` and no retry loop: the caller's accept loop is
73 // already a loop, so `Ok(None)` ("someone connected, but not us") slots
74 // into it as a `continue`. Retrying in here would add a branch that no
75 // test can reach, in the one function where the refusal *is* the point.
76 self.0.accept().await.map(|(stream, _addr)| {
77 peer_is_ours(peer_uid(&stream), leviath_sys::current_uid()).then_some(stream)
78 })
79 }
80}
81
82/// Bind the daemon's control socket at `id`, enforcing a single instance.
83///
84/// If a socket already exists there and a daemon answers, one is already running
85/// ([`std::io::ErrorKind::AddrInUse`]); a leftover file with no listener is
86/// **stale** and is removed before binding. The parent directory is created if
87/// needed.
88pub fn bind_control_listener(id: &Path) -> std::io::Result<ControlListener> {
89 if id.exists() {
90 if std::os::unix::net::UnixStream::connect(id).is_ok() {
91 return Err(std::io::Error::new(
92 std::io::ErrorKind::AddrInUse,
93 "a leviath daemon is already running on this control socket",
94 ));
95 }
96 // Nothing is listening - the socket file is stale; clear it. A failed
97 // remove just means the bind below reports the problem instead.
98 let _ = std::fs::remove_file(id);
99 }
100 // A control-socket path always has a parent directory.
101 let parent = id.parent().expect("control socket path has a parent");
102 std::fs::create_dir_all(parent)?;
103 // Lock the directory down before binding. `create_dir_all` uses the ambient
104 // umask, so on a 0002 umask this was a group-writable directory holding the
105 // daemon's control channel. Best-effort: the directory may already exist and
106 // be owned by someone else, in which case the socket's own mode below is
107 // what carries the guarantee.
108 let _ = leviath_sys::secure_dir_perms(parent);
109
110 let listener = tokio::net::UnixListener::bind(id)?;
111 // Owner-only on the socket itself. On Linux the mode is enforced on
112 // `connect`; on macOS and the BSDs it historically is not, which is why the
113 // per-connection peer check in `accept_authorized` is the real gate and this
114 // is defence in depth rather than the whole story.
115 // Best-effort, like the directory above: `chmod` on a socket we just created
116 // and own does not realistically fail, and the peer check in `accept` - not
117 // this mode - is what actually holds on macOS and the BSDs, where socket
118 // permissions are not consulted at `connect` time.
119 let _ = leviath_sys::secure_file_perms(id);
120 Ok(ControlListener(listener))
121}
122
123/// Whether an accepted connection's peer is this same user.
124///
125/// `None` - the platform could not report a uid - is refused. An unidentifiable
126/// caller is not an authorized one, and failing open here would undo the whole
127/// point on any platform where the lookup is unavailable.
128fn peer_is_ours(peer: Option<u32>, ours: u32) -> bool {
129 match peer {
130 Some(uid) if uid == ours => true,
131 Some(uid) => {
132 tracing::warn!(
133 peer_uid = uid,
134 daemon_uid = ours,
135 "refused a control connection from another user"
136 );
137 false
138 }
139 None => {
140 tracing::warn!("refused a control connection whose peer uid could not be determined");
141 false
142 }
143 }
144}
145
146/// Connect to the daemon's control socket at `id`.
147pub async fn connect(id: &Path) -> std::io::Result<ClientStream> {
148 tokio::net::UnixStream::connect(id).await
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154
155 #[test]
156 fn control_id_is_under_base() {
157 assert_eq!(
158 control_id(Path::new("/x/.leviath")),
159 Path::new("/x/.leviath/control.sock")
160 );
161 }
162
163 #[test]
164 fn control_id_from_str_is_the_path() {
165 assert_eq!(
166 control_id_from_str("/tmp/my.sock"),
167 PathBuf::from("/tmp/my.sock")
168 );
169 }
170
171 /// The module doc claimed "access is governed by ordinary file permissions"
172 /// while setting none of them - both the socket and the directory landed at
173 /// the process umask. Anyone who could connect could spawn a tool-executing
174 /// agent.
175 #[tokio::test]
176 async fn bind_locks_down_the_socket_and_its_directory() {
177 use std::os::unix::fs::PermissionsExt;
178
179 let dir = tempfile::tempdir().unwrap();
180 // A nested path so `bind_control_listener` is the one creating the
181 // directory, which is the case that inherited the umask.
182 let id = dir.path().join("nested").join("control.sock");
183 let _listener = bind_control_listener(&id).unwrap();
184
185 let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
186 assert_eq!(mode(&id), 0o600, "socket must be owner-only");
187 assert_eq!(
188 mode(id.parent().unwrap()),
189 0o700,
190 "socket directory must be owner-only"
191 );
192 }
193
194 /// Both refusal arms, driven against a real socket with the peer lookup
195 /// injected. A connection from a *different* uid needs a second user account
196 /// or root, which no CI runner has - so the lookup is the seam rather than
197 /// the socket.
198 #[tokio::test]
199 async fn accept_skips_connections_that_are_not_ours() {
200 fn foreign_uid(_: &ServerStream) -> Option<u32> {
201 Some(leviath_sys::current_uid().wrapping_add(1))
202 }
203 fn unknown_uid(_: &ServerStream) -> Option<u32> {
204 None
205 }
206
207 for (label, lookup) in [
208 (
209 "another user",
210 foreign_uid as fn(&ServerStream) -> Option<u32>,
211 ),
212 ("an undeterminable uid", unknown_uid),
213 ] {
214 let dir = tempfile::tempdir().unwrap();
215 let id = dir.path().join("control.sock");
216 let mut listener = bind_control_listener(&id).unwrap();
217
218 // Connect first and keep the client alive: a Unix-socket `connect`
219 // completes as soon as the listener has queued it, so no spawned
220 // task is needed - and no abort, whose half-run future would read as
221 // a partially covered region.
222 let _client = connect(&id).await.expect("connecting succeeds");
223 let accepted = listener
224 .accept_with(lookup)
225 .await
226 .expect("accepting itself succeeds");
227 assert!(
228 accepted.is_none(),
229 "a connection from {label} must not be handed to the daemon"
230 );
231 }
232 }
233
234 /// The decision itself: our own uid passes, anything else does not.
235 #[test]
236 fn peer_is_ours_admits_only_this_user() {
237 assert!(peer_is_ours(Some(1000), 1000));
238 assert!(!peer_is_ours(Some(1001), 1000));
239 assert!(!peer_is_ours(None, 1000), "an unknown peer fails closed");
240 }
241
242 /// A connection from this same user is accepted - the peer check must not
243 /// lock the daemon out of its own socket.
244 #[tokio::test]
245 async fn accept_admits_a_connection_from_the_same_user() {
246 let dir = tempfile::tempdir().unwrap();
247 let id = dir.path().join("control.sock");
248 let mut listener = bind_control_listener(&id).unwrap();
249
250 let client = tokio::spawn(async move { connect(&id).await });
251 let accepted = listener.accept().await.expect("accept succeeds");
252 assert!(accepted.is_some(), "same-uid peer must be admitted");
253 client.await.unwrap().unwrap();
254 }
255
256 #[tokio::test]
257 async fn bind_removes_a_stale_socket_file() {
258 let dir = tempfile::tempdir().unwrap();
259 let id = control_id(dir.path());
260 // A leftover regular file where the socket goes: nothing is listening, so
261 // it's stale and must be cleared before binding succeeds.
262 std::fs::write(&id, b"stale").unwrap();
263 let listener = bind_control_listener(&id).unwrap();
264 assert!(id.exists());
265 drop(listener);
266 }
267
268 #[tokio::test]
269 async fn bind_errors_when_parent_cannot_be_created() {
270 let dir = tempfile::tempdir().unwrap();
271 // A regular file where a parent directory would need to be created.
272 let blocker = dir.path().join("blocker");
273 std::fs::write(&blocker, b"x").unwrap();
274 let id = blocker.join("control.sock"); // parent "blocker" is a file
275 assert!(bind_control_listener(&id).is_err());
276 }
277
278 #[tokio::test]
279 async fn bind_errors_when_target_path_is_a_directory() {
280 let dir = tempfile::tempdir().unwrap();
281 let id = dir.path().join("control.sock");
282 std::fs::create_dir(&id).unwrap(); // a directory can't be bound as a socket
283 assert!(bind_control_listener(&id).is_err());
284 }
285}