feat(host): the lease and MCP channels as named pipes with the SID check, and the host, bridge and commands on Windows (P11c)
Windows steps 3 of D-62. Every `cfg(unix)` gate on the host, the bridge, the opener, the books server and the command line is gone: they build on every OS, and the Unix behaviour is unchanged (the existing over-the-wire suites pass on Linux). `ledger_mcp::ipc` is the one seam for the channels. Unix: the same 0600 sockets as before, peer uid unchanged (the peer functions moved here). Windows: a named pipe `\\.\pipe\wanderbooks-<hash of the root's run/>-<name>` made with a DACL of one allow entry for this account's SID, remote clients refused, the first instance made once (a second server is refused, as a held lock refuses it on Unix), and 16 idle instances kept waiting so a client never finds all busy while the server makes the next. Where Unix reads `SO_PEERCRED`, each end of a pipe takes the other's process id (`GetNamedPipeClientProcessId`/`...ServerProcessId`) and compares that process's token user with its own SID; any doubt is "not us". Clients open with SECURITY_IDENTIFICATION so a pipe server cannot act as the client. A blocking client with a read limit is polled with PeekNamedPipe (a pipe read cannot be cut short), and a pipe that has gone reads as the end of the stream, as a closed socket does. Stop signals are Ctrl+C, Ctrl+Break, console close and shutdown where Unix has SIGTERM, SIGINT and SIGHUP. The host starts detached (DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP, leaving the opener's job when the job lets it: an agent's job must not take the host down, as an app's systemd scope must not on Linux) with a fixed environment; `close_range` and `seccomp` stay Linux-only (D-32). A replaced program is told by size and last write. Git for Windows is found and a quiet git is stopped with its tree (`taskkill /T`). The test harness of ledger-cli finds its root by `default_root`, cleans a child's environment per OS and reaches the server over its pipe; the bridge's scripted-server tests stay Unix-only. No new dependency (tokio's named pipes and reqwest's `windows_named_pipe` were already in the build); `windows-sys` stays in ledger-store. Tests: ledger-mcp `ipc::tests` (four on every OS, one Windows only), ledger-host `tests/windows_pipe.rs::a_second_user_cannot_open_the_pipe`, Windows variants of the host and start tests. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
This commit is contained in:
1 parent
5f5dd8f7da
commit
bd6448532e
21 files changed
+1471
-410
No files matched your search
+37
-127
@@ -60,6 +60,7 @@
|
||||
//! ```
|
||||
|
||||
use crate::admin::{self, Request, Response};
|
||||
use crate::ipc::{self, Peer};
|
||||
use crate::server::{BooksServer, Isolation, Served, SharedStore};
|
||||
use ledger_store::Store;
|
||||
use ledger_store::access::{Access, AccessLog, Outcome, Principal};
|
||||
@@ -69,7 +70,6 @@ use rmcp::transport::streamable_http_server::{
|
||||
StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager,
|
||||
};
|
||||
use std::collections::BTreeMap;
|
||||
use std::os::fd::AsRawFd;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
@@ -994,110 +994,25 @@ fn moved_in_at_adoption(
|
||||
}
|
||||
}
|
||||
|
||||
/// The user ID at the other end of a Unix socket.
|
||||
pub fn peer_uid(fd: &impl AsRawFd) -> std::io::Result<u32> {
|
||||
let fd = fd.as_raw_fd();
|
||||
#[cfg(any(target_os = "linux", target_os = "android"))]
|
||||
{
|
||||
let mut cred = libc::ucred {
|
||||
pid: 0,
|
||||
uid: 0,
|
||||
gid: 0,
|
||||
};
|
||||
let mut len = std::mem::size_of::<libc::ucred>() as libc::socklen_t;
|
||||
// SAFETY: `cred` and `len` are valid for writes of the sizes given.
|
||||
let rc = unsafe {
|
||||
libc::getsockopt(
|
||||
fd,
|
||||
libc::SOL_SOCKET,
|
||||
libc::SO_PEERCRED,
|
||||
(&mut cred as *mut libc::ucred).cast(),
|
||||
&mut len,
|
||||
)
|
||||
};
|
||||
if rc != 0 {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
if len as usize != std::mem::size_of::<libc::ucred>() {
|
||||
return Err(std::io::Error::other(
|
||||
"the peer's credentials came back short",
|
||||
));
|
||||
}
|
||||
Ok(cred.uid)
|
||||
}
|
||||
#[cfg(not(any(target_os = "linux", target_os = "android")))]
|
||||
{
|
||||
let mut uid: libc::uid_t = 0;
|
||||
let mut gid: libc::gid_t = 0;
|
||||
// SAFETY: `uid` and `gid` are valid for writes.
|
||||
if unsafe { libc::getpeereid(fd, &mut uid, &mut gid) } != 0 {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
Ok(uid)
|
||||
}
|
||||
}
|
||||
|
||||
/// The process at the other end of a Unix socket, where the system says
|
||||
/// (Linux); `None` elsewhere or if it can't be read.
|
||||
pub fn peer_pid(fd: &impl AsRawFd) -> Option<u32> {
|
||||
#[cfg(any(target_os = "linux", target_os = "android"))]
|
||||
{
|
||||
let mut cred = libc::ucred {
|
||||
pid: 0,
|
||||
uid: 0,
|
||||
gid: 0,
|
||||
};
|
||||
let mut len = std::mem::size_of::<libc::ucred>() as libc::socklen_t;
|
||||
// SAFETY: `cred` and `len` are valid for writes of the sizes given.
|
||||
let rc = unsafe {
|
||||
libc::getsockopt(
|
||||
fd.as_raw_fd(),
|
||||
libc::SOL_SOCKET,
|
||||
libc::SO_PEERCRED,
|
||||
(&mut cred as *mut libc::ucred).cast(),
|
||||
&mut len,
|
||||
)
|
||||
};
|
||||
(rc == 0 && len as usize == std::mem::size_of::<libc::ucred>() && cred.pid > 0)
|
||||
.then_some(cred.pid as u32)
|
||||
}
|
||||
#[cfg(not(any(target_os = "linux", target_os = "android")))]
|
||||
{
|
||||
let _ = fd;
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// This process's user ID.
|
||||
fn own_uid() -> u32 {
|
||||
// SAFETY: geteuid has no preconditions and can't fail.
|
||||
unsafe { libc::geteuid() }
|
||||
}
|
||||
|
||||
/// Whether the other end of a socket is this account (INVARIANT 2).
|
||||
pub fn peer_is_us(fd: &impl AsRawFd) -> bool {
|
||||
peer_uid(fd).is_ok_and(|uid| uid == own_uid())
|
||||
}
|
||||
|
||||
/// The client's check of the server (plan 3.9: both ends check).
|
||||
pub(crate) fn check_peer_std(stream: &std::os::unix::net::UnixStream) -> Result<(), String> {
|
||||
if peer_is_us(stream) {
|
||||
pub(crate) fn check_peer_std(stream: &ipc::StdStream) -> Result<(), String> {
|
||||
if stream.peer_is_us() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("the books server's socket belongs to another account: not used".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// A Unix listener that hands on only this account's connections
|
||||
/// A listener that hands on only this account's connections
|
||||
/// (INVARIANT 2).
|
||||
struct OwnerOnly(tokio::net::UnixListener);
|
||||
struct OwnerOnly(ipc::Listener);
|
||||
|
||||
impl OwnerOnly {
|
||||
async fn next(&mut self) -> tokio::net::UnixStream {
|
||||
async fn next(&mut self) -> ipc::Accepted {
|
||||
loop {
|
||||
match self.0.accept().await {
|
||||
Ok((stream, _)) if peer_is_us(&stream) => return stream,
|
||||
Ok((stream, _)) => {
|
||||
Ok(stream) if stream.peer_is_us() => return stream,
|
||||
Ok(stream) => {
|
||||
eprintln!("wanderbooks server: refused a connection from another account");
|
||||
drop(stream);
|
||||
}
|
||||
@@ -1120,13 +1035,13 @@ impl OwnerOnly {
|
||||
}
|
||||
|
||||
impl axum::serve::Listener for OwnerOnly {
|
||||
type Io = tokio::net::UnixStream;
|
||||
type Addr = tokio::net::unix::SocketAddr;
|
||||
type Io = ipc::Accepted;
|
||||
type Addr = ipc::Addr;
|
||||
|
||||
async fn accept(&mut self) -> (Self::Io, Self::Addr) {
|
||||
loop {
|
||||
let stream = self.next().await;
|
||||
if let Ok(addr) = stream.peer_addr() {
|
||||
if let Ok(addr) = ipc::Listener::addr_of(&stream) {
|
||||
return (stream, addr);
|
||||
}
|
||||
}
|
||||
@@ -1818,9 +1733,9 @@ async fn not_found() -> (axum::http::StatusCode, &'static str) {
|
||||
}
|
||||
|
||||
/// One admin connection: one request, one answer (admin INVARIANT 1).
|
||||
async fn serve_admin(stream: tokio::net::UnixStream, books: Arc<Books>) {
|
||||
async fn serve_admin(stream: ipc::Accepted, books: Arc<Books>) {
|
||||
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt};
|
||||
let (read, mut write) = stream.into_split();
|
||||
let (read, mut write) = ipc::split_accepted(stream);
|
||||
let mut reader = tokio::io::BufReader::new(read.take(admin::MAX_LINE as u64 + 1));
|
||||
let mut line = String::new();
|
||||
// A request still arriving when closing begins is turned away.
|
||||
@@ -1869,18 +1784,8 @@ async fn serve_admin(stream: tokio::net::UnixStream, books: Arc<Books>) {
|
||||
|
||||
/// Bind a socket in `run/`, replacing a stale one (INVARIANT 1: only the
|
||||
/// server holding the lock gets here).
|
||||
fn bind(run: &Path, name: &str) -> std::io::Result<tokio::net::UnixListener> {
|
||||
let path = run.join(name);
|
||||
match std::fs::remove_file(&path) {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
let short = ledger_store::root::SocketPath::new(run, name)?;
|
||||
let listener = tokio::net::UnixListener::bind(short.path())?;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
|
||||
Ok(listener)
|
||||
fn bind(run: &Path, name: &str) -> std::io::Result<ipc::Listener> {
|
||||
ipc::Listener::bind(&ipc::Endpoint::new(run, name)?)
|
||||
}
|
||||
|
||||
/// The address `--http` takes: loopback only (plan 3.9, P9a).
|
||||
@@ -2025,8 +1930,14 @@ pub fn run(
|
||||
}
|
||||
{
|
||||
// Only once it is ours: a refused server touches nothing.
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&lock_path, std::fs::Permissions::from_mode(0o600))
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&lock_path, std::fs::Permissions::from_mode(0o600))
|
||||
.map_err(|e| format!("can't make the server lock private: {}", e.kind()))?;
|
||||
}
|
||||
#[cfg(windows)]
|
||||
ledger_store::winsec::set_owner_only(&lock_path)
|
||||
.map_err(|e| format!("can't make the server lock private: {}", e.kind()))?;
|
||||
}
|
||||
let books = Arc::new(Books {
|
||||
@@ -2292,7 +2203,6 @@ async fn serve(
|
||||
/// SIGTERM, SIGINT or SIGHUP; hurry on the next signal. Runs inside the
|
||||
/// runtime, before the books are opened.
|
||||
fn watch_for_close(stops: &Stops) -> std::io::Result<()> {
|
||||
use tokio::signal::unix::{SignalKind, signal};
|
||||
let on_eof = stops.close.clone();
|
||||
// A plain thread: a blocked read must never hold up the runtime's end.
|
||||
std::thread::Builder::new()
|
||||
@@ -2311,12 +2221,7 @@ fn watch_for_close(stops: &Stops) -> std::io::Result<()> {
|
||||
}
|
||||
on_eof.cancel();
|
||||
})?;
|
||||
for kind in [
|
||||
SignalKind::terminate(),
|
||||
SignalKind::interrupt(),
|
||||
SignalKind::hangup(),
|
||||
] {
|
||||
let mut signals = signal(kind)?;
|
||||
for mut signals in ipc::stop_signals()? {
|
||||
let (close, hurry) = (stops.close.clone(), stops.hurry.clone());
|
||||
tokio::spawn(async move {
|
||||
while signals.recv().await.is_some() {
|
||||
@@ -2613,34 +2518,38 @@ mod tests {
|
||||
assert_eq!(backup_due(Some(&w), 5, at(9 + retry * 3), quiet), None);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_socket_pair_is_this_account_at_both_ends() {
|
||||
let (a, b) = std::os::unix::net::UnixStream::pair().unwrap();
|
||||
assert_eq!(peer_uid(&a).unwrap(), own_uid());
|
||||
assert!(peer_is_us(&b));
|
||||
assert_eq!(ipc::peer_uid(&a).unwrap(), ipc::own_uid());
|
||||
assert!(ipc::peer_is_us(&b));
|
||||
assert!(check_peer_std(&a).is_ok());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_socket_pair_names_this_process() {
|
||||
let (a, _b) = std::os::unix::net::UnixStream::pair().unwrap();
|
||||
if cfg!(target_os = "linux") {
|
||||
assert_eq!(peer_pid(&a), Some(std::process::id()));
|
||||
assert_eq!(ipc::peer_pid(&a), Some(std::process::id()));
|
||||
}
|
||||
let file = std::fs::File::open("/dev/null").unwrap();
|
||||
assert_eq!(peer_pid(&file), None);
|
||||
assert_eq!(ipc::peer_pid(&file), None);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_descriptor_that_isnt_a_socket_is_never_taken_for_us() {
|
||||
let file = std::fs::File::open("/dev/null").unwrap();
|
||||
assert!(peer_uid(&file).is_err());
|
||||
assert!(!peer_is_us(&file));
|
||||
assert!(ipc::peer_uid(&file).is_err());
|
||||
assert!(!ipc::peer_is_us(&file));
|
||||
}
|
||||
|
||||
/// A socket with no other end: the system answers for it (Linux: no
|
||||
/// process, no user), and that answer names neither this process nor
|
||||
/// this account; the client's check refuses it.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_socket_with_no_other_end_names_no_process_and_isnt_us() {
|
||||
use std::os::fd::FromRawFd;
|
||||
@@ -2650,8 +2559,8 @@ mod tests {
|
||||
assert!(fd >= 0);
|
||||
// SAFETY: a fresh descriptor of a Unix stream socket, owned by none.
|
||||
let lone = unsafe { std::os::unix::net::UnixStream::from_raw_fd(fd) };
|
||||
assert_eq!(peer_pid(&lone), None);
|
||||
assert!(!peer_is_us(&lone));
|
||||
assert_eq!(ipc::peer_pid(&lone), None);
|
||||
assert!(!ipc::peer_is_us(&lone));
|
||||
assert_eq!(
|
||||
check_peer_std(&lone).unwrap_err(),
|
||||
"the books server's socket belongs to another account: not used"
|
||||
@@ -3196,6 +3105,7 @@ mod tests {
|
||||
let id = BookId::parse(id).unwrap();
|
||||
let dir = books.root.book_dir(&id);
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
#[cfg(unix)]
|
||||
std::fs::set_permissions(&dir, std::os::unix::fs::PermissionsExt::from_mode(0o700))
|
||||
.unwrap();
|
||||
let mut s =
|
||||
|
||||
Reference in new issue
Block a user