Skip to main content

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}