Skip to main content

mcpmesh_local_api/transport/
unix.rs

1//! Unix impl of the transport seam: the ONE hardened UDS rule (moved verbatim from
2//! service.rs — same rule, same home, now platform-shaped).
3use std::io;
4use std::path::Path;
5use tokio::net::UnixStream;
6
7pub type LocalStream = UnixStream;
8pub type LocalReadHalf = tokio::net::unix::OwnedReadHalf;
9pub type LocalWriteHalf = tokio::net::unix::OwnedWriteHalf;
10
11/// Owned split (task-spawnable halves). Zero-cost on unix: `UnixStream::into_split`.
12pub fn split_local(stream: LocalStream) -> (LocalReadHalf, LocalWriteHalf) {
13    stream.into_split()
14}
15
16/// Connect the per-user local endpoint. Unix: plain `UnixStream::connect` (a dead
17/// socket yields NotFound/ConnectionRefused — the autostart trigger upstream).
18pub async fn connect_local(path: &Path) -> io::Result<LocalStream> {
19    UnixStream::connect(path).await
20}
21
22#[cfg(feature = "service")]
23mod server {
24    use super::*;
25    use std::os::unix::fs::PermissionsExt;
26    use tokio::net::UnixListener;
27
28    // ── ensure_private_dir, bind_uds, check_peer_uid: moved VERBATIM from service.rs,
29    //    visibility now `pub` (they are re-exported by service.rs for the plugin seam) ──
30
31    /// Create + security-check a private runtime dir (ONE hardened rule for every
32    /// UDS face in the family; the daemon control socket and the plugin seam both bind through
33    /// it): `create_dir_all`, refuse a symlink, chmod 0700, verify we own it. Idempotent.
34    ///
35    /// The checks are load-bearing, not decorative: `create_dir_all` is a no-op when the dir
36    /// already exists, so a pre-existing dir planted by another user — or a symlink redirecting
37    /// to one we own (or one we don't) — must be refused before we trust it to hold a socket.
38    /// `symlink_metadata` does not follow the link, and it runs BEFORE the chmod so a planted
39    /// symlink can never make us chmod its target.
40    pub fn ensure_private_dir(dir: &Path) -> io::Result<()> {
41        use std::os::unix::fs::MetadataExt;
42        std::fs::create_dir_all(dir)?;
43        let is_symlink = std::fs::symlink_metadata(dir)?.file_type().is_symlink();
44        if is_symlink {
45            return Err(io::Error::other(format!(
46                "runtime dir {} is a symlink; refusing",
47                dir.display()
48            )));
49        }
50        std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
51        let meta = std::fs::metadata(dir)?;
52        if meta.uid() != rustix::process::geteuid().as_raw() {
53            return Err(io::Error::other(format!(
54                "runtime dir {} is not owned by us",
55                dir.display()
56            )));
57        }
58        Ok(())
59    }
60
61    /// Bind a listener at `path`: harden the parent runtime dir ([`ensure_private_dir`] —
62    /// create, refuse-symlink, chmod 0700, verify ownership), remove any stale socket, bind,
63    /// and chmod the socket 0600.
64    ///
65    /// The parent dir is forced private because the `XDG_RUNTIME_DIR`
66    /// fallback is `std::env::temp_dir()`, whose subdirs are NOT otherwise guaranteed private.
67    /// The 0700 dir + 0600 socket are defense in depth only — [`check_peer_uid`] remains the
68    /// real gate on every accepted connection.
69    pub fn bind_uds(path: &Path) -> io::Result<UnixListener> {
70        if let Some(parent) = path.parent()
71            && !parent.as_os_str().is_empty()
72        {
73            ensure_private_dir(parent)?;
74        }
75        // A leftover socket file from a crashed daemon blocks bind with EADDRINUSE.
76        let _ = std::fs::remove_file(path);
77        let listener = UnixListener::bind(path)
78            .map_err(|e| io::Error::new(e.kind(), format!("bind {path:?}: {e}")))?;
79        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
80            .map_err(|e| io::Error::new(e.kind(), format!("chmod 0600 {path:?}: {e}")))?;
81        Ok(listener)
82    }
83
84    /// Is this connection's peer the same uid as us? `false` (refuse) on a different uid OR an
85    /// unreadable peer credential — default-deny, defense in depth beyond the 0600 socket.
86    // Implementation: `UnixStream::peer_cred()` -> `UCred::uid()`; `rustix::process::geteuid()`.
87    pub fn check_peer_uid(stream: &UnixStream) -> bool {
88        let Ok(cred) = stream.peer_cred() else {
89            tracing::warn!("peer_cred unreadable: refusing local connection");
90            return false;
91        };
92        let peer = cred.uid();
93        let me = rustix::process::geteuid().as_raw();
94        if peer != me {
95            tracing::warn!(peer, me, "refusing cross-uid local connection");
96            return false;
97        }
98        true
99    }
100
101    pub struct LocalListener(UnixListener);
102
103    impl LocalListener {
104        /// Accept one connection. `&mut self` for windows-API parity (the pipe
105        /// listener must rotate instances); harmless here.
106        pub async fn accept(&mut self) -> io::Result<LocalStream> {
107            let (stream, _addr) = self.0.accept().await?;
108            Ok(stream)
109        }
110    }
111
112    /// Same-user gate on an accepted connection. Unix: the peer-euid check (the REAL
113    /// authorization gate, defense in depth beyond the 0600 socket). On Windows the
114    /// owner-only DACL already refused other users at connect, so its impl is `true`.
115    pub fn authorize_local_peer(stream: &LocalStream) -> bool {
116        check_peer_uid(stream)
117    }
118
119    /// Bind the per-user local endpoint at `path`, fully hardened for the platform:
120    /// parent dir 0700 + symlink-refused + owned, stale socket removed,
121    /// socket 0600. (On Windows this is a first-instance owner-only-DACL pipe and a
122    /// second daemon's bind fails AddrInUse — the Windows singleton.)
123    pub fn bind_local(path: &Path) -> io::Result<LocalListener> {
124        Ok(LocalListener(bind_uds(path)?))
125    }
126}
127#[cfg(feature = "service")]
128pub use server::*;