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::*;