//! Local IPC between `kestrove-nmh` (started by the browser) and the running app. //! //! * Unix: a socket in a 0700 per-user directory, socket mode 0600, and a peer-credential check //! on both ends (`SO_PEERCRED` on Linux, `getpeereid` on macOS/BSD). Linux uses //! `$XDG_RUNTIME_DIR/kestrove/nmh.sock`; macOS uses the per-user temp dir from //! `confstr(_CS_DARWIN_USER_TEMP_DIR)`. //! * Windows: the named pipe `\\.\pipe\kestrove-nmh-`, created first-instance-only (no //! squatting), local clients only, with a DACL for the current user, plus a client token check. //! //! The channel carries only already-encrypted pairing frames (see `channel`), so this layer keeps //! other users and remote machines out; the pairing key keeps other same-user processes out. //! Traffic is strictly request → response: no concurrent read and write on one connection //! (synchronous Windows pipe handles serialize I/O, so that would deadlock). use std::io::{self, Read, Write}; use std::path::PathBuf; /// Overrides the IPC directory (Unix) or pipe name suffix (Windows). Tests use it to run an /// isolated app; the browser passes its environment through to the host, so a browser started /// with it set reaches that app. pub const ENV_IPC_DIR: &str = "KESTROVE_IPC_DIR"; pub use imp::{connect, Conn, Listener}; /// Where the socket lives (Unix) or a display string for the pipe (Windows). pub fn endpoint() -> io::Result { imp::endpoint() } impl Read for Conn { fn read(&mut self, buf: &mut [u8]) -> io::Result { self.0.read(buf) } } impl Write for Conn { fn write(&mut self, buf: &[u8]) -> io::Result { self.0.write(buf) } fn flush(&mut self) -> io::Result<()> { self.0.flush() } } #[cfg(unix)] mod imp { use std::fs; use std::io; use std::os::unix::fs::{DirBuilderExt, MetadataExt, PermissionsExt}; use std::os::unix::io::AsRawFd; use std::os::unix::net::{UnixListener, UnixStream}; use std::path::PathBuf; use std::time::Duration; use super::ENV_IPC_DIR; pub struct Conn(pub(super) UnixStream); pub struct Listener { inner: UnixListener, path: PathBuf, } fn uid() -> u32 { // SAFETY: geteuid has no preconditions and cannot fail. unsafe { libc::geteuid() } } fn base_dir() -> io::Result { if let Some(d) = std::env::var_os(ENV_IPC_DIR).filter(|d| !d.is_empty()) { return Ok(PathBuf::from(d)); } platform_dir() } #[cfg(target_os = "macos")] fn platform_dir() -> io::Result { use std::ffi::{CStr, OsStr}; use std::os::unix::ffi::OsStrExt; let mut buf = vec![0u8; 1024]; // SAFETY: buf is valid for buf.len() bytes; confstr NUL-terminates within that length. let n = unsafe { libc::confstr(libc::_CS_DARWIN_USER_TEMP_DIR, buf.as_mut_ptr().cast(), buf.len()) }; if n == 0 || n > buf.len() { return Err(io::Error::last_os_error()); } let s = CStr::from_bytes_until_nul(&buf).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; Ok(PathBuf::from(OsStr::from_bytes(s.to_bytes())).join("kestrove")) } #[cfg(not(target_os = "macos"))] fn platform_dir() -> io::Result { match std::env::var_os("XDG_RUNTIME_DIR").map(PathBuf::from) { Some(d) if d.is_absolute() => Ok(d.join("kestrove")), // No runtime dir (e.g. no systemd session): a per-uid dir in /tmp, ownership checked in secure_dir. _ => Ok(PathBuf::from(format!("/tmp/kestrove-{}", uid()))), } } pub fn endpoint() -> io::Result { Ok(base_dir()?.join("nmh.sock")) } /// Creates the directory 0700, or checks that an existing one is ours and not group/world accessible. fn secure_dir(dir: &std::path::Path) -> io::Result<()> { match fs::DirBuilder::new().recursive(true).mode(0o700).create(dir) { Ok(()) => {} Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {} Err(e) => return Err(e), } let meta = fs::symlink_metadata(dir)?; if !meta.is_dir() || meta.uid() != uid() { return Err(io::Error::new(io::ErrorKind::PermissionDenied, "IPC directory is not owned by this user")); } if meta.mode() & 0o077 != 0 { fs::set_permissions(dir, fs::Permissions::from_mode(0o700))?; } Ok(()) } #[cfg(any(target_os = "linux", target_os = "android"))] pub(super) fn peer_uid(s: &UnixStream) -> io::Result { let mut cred = libc::ucred { pid: 0, uid: 0, gid: 0 }; let mut len = std::mem::size_of::() as libc::socklen_t; // SAFETY: cred and len are valid out-pointers of the sizes passed. let rc = unsafe { libc::getsockopt( s.as_raw_fd(), libc::SOL_SOCKET, libc::SO_PEERCRED, (&mut cred as *mut libc::ucred).cast(), &mut len, ) }; if rc != 0 { return Err(io::Error::last_os_error()); } Ok(cred.uid) } #[cfg(not(any(target_os = "linux", target_os = "android")))] pub(super) fn peer_uid(s: &UnixStream) -> io::Result { let (mut uid, mut gid) = (0, 0); // SAFETY: uid and gid are valid out-pointers. if unsafe { libc::getpeereid(s.as_raw_fd(), &mut uid, &mut gid) } != 0 { return Err(io::Error::last_os_error()); } Ok(uid) } fn check_peer(s: &UnixStream) -> io::Result<()> { if peer_uid(s)? != uid() { return Err(io::Error::new(io::ErrorKind::PermissionDenied, "IPC peer is a different user")); } Ok(()) } impl Listener { /// Binds the socket. Fails with `AddrInUse` if another app instance is already serving it. pub fn bind() -> io::Result { let path = endpoint()?; secure_dir(path.parent().expect("socket path has a parent"))?; if path.exists() { if UnixStream::connect(&path).is_ok() { return Err(io::Error::new(io::ErrorKind::AddrInUse, "Kestrove is already running")); } fs::remove_file(&path)?; // stale socket from a crashed instance } let inner = UnixListener::bind(&path)?; fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?; Ok(Self { inner, path }) } /// Blocks for the next same-user client. Other users' connections are dropped and skipped. pub fn accept(&self) -> io::Result { loop { let (s, _) = self.inner.accept()?; if check_peer(&s).is_ok() { // A stuck peer must not pin a thread forever. s.set_read_timeout(Some(Duration::from_secs(600)))?; return Ok(Conn(s)); } } } } impl Drop for Listener { fn drop(&mut self) { let _ = fs::remove_file(&self.path); } } pub fn connect() -> io::Result { let s = UnixStream::connect(endpoint()?)?; check_peer(&s)?; // don't talk to a socket another user planted Ok(Conn(s)) } } #[cfg(windows)] mod imp { use std::fs::File; use std::io; use std::os::windows::ffi::OsStrExt; use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; use std::path::PathBuf; use std::ptr; use windows_sys::Win32::Foundation::{ CloseHandle, GetLastError, LocalFree, ERROR_PIPE_CONNECTED, GENERIC_READ, GENERIC_WRITE, HANDLE, INVALID_HANDLE_VALUE, }; use windows_sys::Win32::Security::Authorization::{ ConvertSidToStringSidW, ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1, }; use windows_sys::Win32::Security::{ EqualSid, GetTokenInformation, TokenUser, PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES, TOKEN_QUERY, TOKEN_USER, }; use windows_sys::Win32::Storage::FileSystem::{ CreateFileW, FILE_FLAG_FIRST_PIPE_INSTANCE, OPEN_EXISTING, PIPE_ACCESS_DUPLEX, }; use windows_sys::Win32::System::Pipes::{ ConnectNamedPipe, CreateNamedPipeW, GetNamedPipeClientProcessId, GetNamedPipeServerProcessId, PIPE_READMODE_BYTE, PIPE_REJECT_REMOTE_CLIENTS, PIPE_TYPE_BYTE, PIPE_UNLIMITED_INSTANCES, PIPE_WAIT, }; use windows_sys::Win32::System::Threading::{ GetCurrentProcess, OpenProcess, OpenProcessToken, PROCESS_QUERY_LIMITED_INFORMATION, }; use super::ENV_IPC_DIR; pub struct Conn(pub(super) File); pub struct Listener { name: Vec, sddl: Vec, first: std::sync::atomic::AtomicBool, } fn wide(s: &str) -> Vec { std::ffi::OsStr::new(s).encode_wide().chain(Some(0)).collect() } fn pipe_name() -> String { let suffix = std::env::var(ENV_IPC_DIR) .ok() .filter(|s| !s.is_empty()) .or_else(|| std::env::var("USERNAME").ok()) .unwrap_or_else(|| "user".into()); let clean: String = suffix.chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }).collect(); format!(r"\\.\pipe\kestrove-nmh-{clean}") } pub fn endpoint() -> io::Result { Ok(PathBuf::from(pipe_name())) } /// The TOKEN_USER buffer of a process (the SID pointer points into it). fn token_user(process: HANDLE) -> io::Result> { let mut token: HANDLE = ptr::null_mut(); // SAFETY: valid process handle and out-pointer. if unsafe { OpenProcessToken(process, TOKEN_QUERY, &mut token) } == 0 { return Err(io::Error::last_os_error()); } // SAFETY: we own the token handle from here on. let token = unsafe { OwnedHandle::from_raw_handle(token) }; let mut len = 0u32; // SAFETY: a size query with a null buffer is the documented pattern. unsafe { GetTokenInformation(token.as_raw_handle(), TokenUser, ptr::null_mut(), 0, &mut len) }; let mut buf = vec![0u8; len as usize]; // SAFETY: buf has len bytes. if unsafe { GetTokenInformation(token.as_raw_handle(), TokenUser, buf.as_mut_ptr().cast(), len, &mut len) } == 0 { return Err(io::Error::last_os_error()); } Ok(buf) } fn sid_of(buf: &[u8]) -> *mut core::ffi::c_void { // SAFETY: buf came from GetTokenInformation(TokenUser) and starts with a TOKEN_USER. unsafe { (*(buf.as_ptr() as *const TOKEN_USER)).User.Sid } } fn same_user_as_process(pid: u32) -> io::Result<()> { // SAFETY: plain Win32 calls; every handle is closed before returning. unsafe { let ours = token_user(GetCurrentProcess())?; let h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid); if h.is_null() { return Err(io::Error::last_os_error()); } let theirs = token_user(h); CloseHandle(h); let theirs = theirs?; if EqualSid(sid_of(&ours), sid_of(&theirs)) == 0 { return Err(io::Error::new(io::ErrorKind::PermissionDenied, "IPC peer is a different user")); } } Ok(()) } /// SDDL granting full access to the current user only (no Everyone read like the default DACL). fn user_only_sddl() -> io::Result> { // SAFETY: ConvertSidToStringSidW allocates with LocalAlloc; we free it after copying. unsafe { let ours = token_user(GetCurrentProcess())?; let mut s: *mut u16 = ptr::null_mut(); if ConvertSidToStringSidW(sid_of(&ours), &mut s) == 0 { return Err(io::Error::last_os_error()); } let mut n = 0; while *s.add(n) != 0 { n += 1; } let sid = String::from_utf16_lossy(std::slice::from_raw_parts(s, n)); LocalFree(s.cast()); Ok(wide(&format!("D:P(A;;GA;;;{sid})"))) } } impl Listener { pub fn bind() -> io::Result { let me = Self { name: wide(&pipe_name()), sddl: user_only_sddl()?, first: std::sync::atomic::AtomicBool::new(true), }; Ok(me) } fn create_instance(&self) -> io::Result { let first = self.first.swap(false, std::sync::atomic::Ordering::SeqCst); let mut sd: PSECURITY_DESCRIPTOR = ptr::null_mut(); // SAFETY: sddl is NUL-terminated; sd is freed with LocalFree below. unsafe { if ConvertStringSecurityDescriptorToSecurityDescriptorW( self.sddl.as_ptr(), SDDL_REVISION_1, &mut sd, ptr::null_mut(), ) == 0 { return Err(io::Error::last_os_error()); } let sa = SECURITY_ATTRIBUTES { nLength: std::mem::size_of::() as u32, lpSecurityDescriptor: sd, bInheritHandle: 0, }; let h = CreateNamedPipeW( self.name.as_ptr(), PIPE_ACCESS_DUPLEX | if first { FILE_FLAG_FIRST_PIPE_INSTANCE } else { 0 }, PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT | PIPE_REJECT_REMOTE_CLIENTS, PIPE_UNLIMITED_INSTANCES, 64 * 1024, 64 * 1024, 0, &sa, ); LocalFree(sd); if h == INVALID_HANDLE_VALUE { let e = io::Error::last_os_error(); return Err(if first { io::Error::new(io::ErrorKind::AddrInUse, e) } else { e }); } Ok(h) } } pub fn accept(&self) -> io::Result { loop { let h = self.create_instance()?; // SAFETY: h is a fresh pipe handle owned by `file` from here on. let file = unsafe { File::from_raw_handle(h) }; // SAFETY: blocking connect on our own pipe handle. let ok = unsafe { ConnectNamedPipe(h, ptr::null_mut()) } != 0 || unsafe { GetLastError() } == ERROR_PIPE_CONNECTED; if !ok { continue; } let mut pid = 0u32; // SAFETY: valid handle and out-pointer. if unsafe { GetNamedPipeClientProcessId(h, &mut pid) } != 0 && same_user_as_process(pid).is_ok() { return Ok(Conn(file)); } } } } pub fn connect() -> io::Result { let name = wide(&pipe_name()); // SAFETY: name is NUL-terminated; the handle is owned by File on success. let h = unsafe { CreateFileW(name.as_ptr(), GENERIC_READ | GENERIC_WRITE, 0, ptr::null(), OPEN_EXISTING, 0, ptr::null_mut()) }; if h == INVALID_HANDLE_VALUE { return Err(io::Error::last_os_error()); } // SAFETY: h is a valid, owned handle. let file = unsafe { File::from_raw_handle(h) }; let mut pid = 0u32; // SAFETY: valid handle and out-pointer. if unsafe { GetNamedPipeServerProcessId(h, &mut pid) } == 0 { return Err(io::Error::last_os_error()); } same_user_as_process(pid)?; Ok(Conn(file)) } } #[cfg(all(test, unix))] mod tests { use super::*; use crate::framing::{read_json, write_json}; use serde_json::json; use std::os::unix::fs::PermissionsExt; #[test] fn socket_is_private_and_round_trips() { let dir = tempfile::tempdir().unwrap(); let ipc_dir = dir.path().join("ipc"); // SAFETY: tests in this module are the only ones touching this variable. unsafe { std::env::set_var(ENV_IPC_DIR, &ipc_dir) }; let l = Listener::bind().unwrap(); let path = endpoint().unwrap(); assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600); assert_eq!(std::fs::metadata(&ipc_dir).unwrap().permissions().mode() & 0o777, 0o700); let t = std::thread::spawn(move || { let mut c = l.accept().unwrap(); let v = read_json(&mut c).unwrap().unwrap(); write_json(&mut c, &json!({"echo": v})).unwrap(); l }); let mut c = connect().unwrap(); write_json(&mut c, &json!({"t": "ping"})).unwrap(); assert_eq!(read_json(&mut c).unwrap(), Some(json!({"echo": {"t": "ping"}}))); let l = t.join().unwrap(); // A second instance sees the live socket and refuses to steal it. assert_eq!(Listener::bind().err().map(|e| e.kind()), Some(io::ErrorKind::AddrInUse)); drop(l); assert!(!path.exists(), "socket removed on shutdown"); } }