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
+129
-62
@@ -53,6 +53,7 @@ use crate::knobs::{FLAG, Knobs};
|
||||
use crate::lease::{self, Ask, HostMsg, Intent, LeaseInfo, Reason};
|
||||
use ledger_store::root::Root;
|
||||
use std::collections::BTreeMap;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
@@ -80,7 +81,23 @@ const LOG_LIMIT: u64 = 8 * 1024 * 1024;
|
||||
/// The exit code when another host holds the root.
|
||||
pub const EXIT_ANOTHER_HOST: i32 = 3;
|
||||
|
||||
/// File options for what the host makes: mode 0600 on Unix; on Windows
|
||||
/// the file takes the owner-only DACL its folder carries (P11c).
|
||||
fn private_options() -> std::fs::OpenOptions {
|
||||
#[allow(unused_mut)]
|
||||
let mut options = std::fs::OpenOptions::new();
|
||||
#[cfg(unix)]
|
||||
options.mode(0o600);
|
||||
options
|
||||
}
|
||||
|
||||
/// Windows passes no descriptor to a child unless asked to, so there is
|
||||
/// nothing to close (INVARIANT 2).
|
||||
#[cfg(not(unix))]
|
||||
fn close_stray_descriptors() {}
|
||||
|
||||
/// Close every descriptor above 2 (INVARIANT 2).
|
||||
#[cfg(unix)]
|
||||
fn close_stray_descriptors() {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
@@ -112,12 +129,7 @@ fn confined(run: &Path) -> Option<&'static str> {
|
||||
}
|
||||
}
|
||||
let probe = run.join(format!(".host-probe-{}", std::process::id()));
|
||||
match std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o600)
|
||||
.open(&probe)
|
||||
{
|
||||
match private_options().write(true).create_new(true).open(&probe) {
|
||||
Ok(_) => {
|
||||
let _ = std::fs::remove_file(&probe);
|
||||
None
|
||||
@@ -138,11 +150,10 @@ fn take_host_lock(run: &Path) -> Result<Option<std::fs::File>, String> {
|
||||
/// [`take_host_lock`], waiting at most `wait` (tests shorten it).
|
||||
fn take_host_lock_within(run: &Path, wait: Duration) -> Result<Option<std::fs::File>, String> {
|
||||
let path = run.join(lease::HOST_LOCK);
|
||||
let file = std::fs::OpenOptions::new()
|
||||
let file = private_options()
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.write(true)
|
||||
.mode(0o600)
|
||||
.open(&path)
|
||||
.map_err(|e| format!("can't open the host lock: {}", e.kind()))?;
|
||||
let deadline = std::time::Instant::now() + wait;
|
||||
@@ -180,11 +191,7 @@ fn open_log_within(path: &Path, limit: u64) -> std::io::Result<std::fs::File> {
|
||||
if std::fs::metadata(path).is_ok_and(|m| m.len() > limit) {
|
||||
let _ = std::fs::rename(path, path.with_extension("log.1"));
|
||||
}
|
||||
std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.mode(0o600)
|
||||
.open(path)
|
||||
private_options().create(true).append(true).open(path)
|
||||
}
|
||||
|
||||
/// The executable the server is started from (INVARIANT 3).
|
||||
@@ -196,9 +203,13 @@ fn children_executable() -> PathBuf {
|
||||
}
|
||||
}
|
||||
|
||||
/// A program file's identity: what changes when the file is replaced.
|
||||
type Identity = (u64, u64);
|
||||
|
||||
/// The running executable's file identity, and its file's now: they
|
||||
/// differ once the file was replaced (INVARIANT 7).
|
||||
fn running_identity() -> Option<(u64, u64)> {
|
||||
#[cfg(unix)]
|
||||
fn running_identity() -> Option<Identity> {
|
||||
let running = if cfg!(target_os = "linux") {
|
||||
std::fs::metadata("/proc/self/exe").ok()?
|
||||
} else {
|
||||
@@ -207,19 +218,46 @@ fn running_identity() -> Option<(u64, u64)> {
|
||||
Some((running.dev(), running.ino()))
|
||||
}
|
||||
|
||||
/// Windows can't replace a program that runs, so an update puts the new
|
||||
/// file at the old path (the old one moved aside): the identity is the
|
||||
/// file's size and last write at the path, read when the host started.
|
||||
#[cfg(windows)]
|
||||
fn running_identity() -> Option<Identity> {
|
||||
identity_of(&std::env::current_exe().ok()?)
|
||||
}
|
||||
|
||||
/// The identity of the file at this executable's path, if it is a
|
||||
/// program (a file half-written or gone doesn't count yet).
|
||||
fn file_identity() -> Option<(u64, u64)> {
|
||||
fn file_identity() -> Option<Identity> {
|
||||
identity_of(&crate::start::this_executable().ok()?)
|
||||
}
|
||||
|
||||
/// The identity (device, inode) of the program at `path`: a file someone
|
||||
/// may run; else None.
|
||||
fn identity_of(path: &Path) -> Option<(u64, u64)> {
|
||||
#[cfg(unix)]
|
||||
fn identity_of(path: &Path) -> Option<Identity> {
|
||||
let meta = std::fs::metadata(path).ok()?;
|
||||
(meta.is_file() && meta.permissions().mode() & 0o111 != 0).then(|| (meta.dev(), meta.ino()))
|
||||
}
|
||||
|
||||
/// The identity (size, last write in nanoseconds) of the program at
|
||||
/// `path`: a file; else None.
|
||||
#[cfg(windows)]
|
||||
fn identity_of(path: &Path) -> Option<Identity> {
|
||||
let meta = std::fs::metadata(path).ok()?;
|
||||
let written = meta
|
||||
.modified()
|
||||
.ok()?
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.ok()?;
|
||||
meta.is_file().then(|| {
|
||||
(
|
||||
meta.len(),
|
||||
u64::try_from(written.as_nanos()).unwrap_or(u64::MAX),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Wait at most `wait` for a host to answer on the lease socket: whether
|
||||
/// one did.
|
||||
fn wait_for_a_host(run: &Path, wait: Duration) -> bool {
|
||||
@@ -347,19 +385,29 @@ pub fn run(
|
||||
/// Start the next host from the file now on disk, with this one's
|
||||
/// arguments and (already clean) environment (INVARIANT 7).
|
||||
fn restart() -> Result<(), String> {
|
||||
use std::os::unix::process::CommandExt;
|
||||
let exe = crate::start::this_executable()?;
|
||||
let mut c = std::process::Command::new(exe);
|
||||
c.args(std::env::args_os().skip(1))
|
||||
.current_dir("/")
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null());
|
||||
// SAFETY: setsid is async-signal-safe and touches only the child.
|
||||
unsafe {
|
||||
c.pre_exec(|| {
|
||||
libc::setsid();
|
||||
Ok(())
|
||||
});
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::process::CommandExt;
|
||||
c.current_dir("/");
|
||||
// SAFETY: setsid is async-signal-safe and touches only the child.
|
||||
unsafe {
|
||||
c.pre_exec(|| {
|
||||
libc::setsid();
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
// Detached from this console and its Ctrl+C, as a new session is
|
||||
// on Unix.
|
||||
c.creation_flags(crate::start::DETACHED_HOST);
|
||||
}
|
||||
c.spawn()
|
||||
.map(drop)
|
||||
@@ -456,20 +504,11 @@ impl Host {
|
||||
/// Bind the lease socket, start the server, and serve leases until
|
||||
/// the books close.
|
||||
async fn serve(mut self) -> Result<Ending, String> {
|
||||
let listener = bind(&self.run)?;
|
||||
let mut listener = bind(&self.run)?;
|
||||
let (events, mut inbox) = mpsc::unbounded_channel::<Event>();
|
||||
let connections = TaskTracker::new();
|
||||
let mut signals = Vec::new();
|
||||
for kind in [
|
||||
tokio::signal::unix::SignalKind::terminate(),
|
||||
tokio::signal::unix::SignalKind::interrupt(),
|
||||
tokio::signal::unix::SignalKind::hangup(),
|
||||
] {
|
||||
signals.push(
|
||||
tokio::signal::unix::signal(kind)
|
||||
.map_err(|e| format!("can't watch for signals: {e}"))?,
|
||||
);
|
||||
}
|
||||
let signals =
|
||||
ledger_mcp::ipc::stop_signals().map_err(|e| format!("can't watch for signals: {e}"))?;
|
||||
let (stop_tx, mut stop) = mpsc::unbounded_channel::<()>();
|
||||
for mut s in signals {
|
||||
let stop_tx = stop_tx.clone();
|
||||
@@ -513,9 +552,9 @@ impl Host {
|
||||
}
|
||||
}
|
||||
accepted = listener.accept() => match accepted {
|
||||
Ok((stream, _)) if ledger_mcp::local::peer_is_us(&stream) => {
|
||||
Ok(stream) if ledger_mcp::ipc::peer_is_us(&stream) => {
|
||||
// The holder's process, as the kernel knows it.
|
||||
let pid = ledger_mcp::local::peer_pid(&stream);
|
||||
let pid = ledger_mcp::ipc::peer_pid(&stream);
|
||||
connections.spawn(connection(stream, pid, events.clone()));
|
||||
}
|
||||
Ok(_) => {}
|
||||
@@ -674,7 +713,9 @@ impl Host {
|
||||
let log = open_log(&self.run.join(SERVER_LOG))
|
||||
.map_err(|e| format!("can't open the server's log: {}", e.kind()))?;
|
||||
let mut command = tokio::process::Command::new(children_executable());
|
||||
command.arg0("wanderbooks").arg("server");
|
||||
#[cfg(unix)]
|
||||
command.arg0("wanderbooks");
|
||||
command.arg("server");
|
||||
if self.explicit_root {
|
||||
command.arg("--root").arg(self.root.path());
|
||||
}
|
||||
@@ -815,11 +856,10 @@ fn consented(socket: &Path, book: String, passphrase: ledger_mcp::admin::Passphr
|
||||
|
||||
/// Take run/update.lock (exclusive), if it is free now.
|
||||
fn take_update_lock(run: &Path) -> Result<std::fs::File, String> {
|
||||
let file = std::fs::OpenOptions::new()
|
||||
let file = private_options()
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.write(true)
|
||||
.mode(0o600)
|
||||
.open(run.join(lease::UPDATE_LOCK))
|
||||
.map_err(|e| format!("can't open the update lock: {}", e.kind()))?;
|
||||
match file.try_lock() {
|
||||
@@ -834,31 +874,22 @@ fn take_update_lock(run: &Path) -> Result<std::fs::File, String> {
|
||||
}
|
||||
|
||||
/// Bind run/lease.sock, replacing a stale one (only the host holding the
|
||||
/// lock gets here).
|
||||
fn bind(run: &Path) -> Result<tokio::net::UnixListener, String> {
|
||||
let path = run.join(lease::LEASE_SOCKET);
|
||||
match std::fs::remove_file(&path) {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => return Err(format!("can't replace the lease socket: {}", e.kind())),
|
||||
}
|
||||
let short = ledger_store::root::SocketPath::new(run, lease::LEASE_SOCKET)
|
||||
/// lock gets here); on Windows the lease pipe of the root.
|
||||
fn bind(run: &Path) -> Result<ledger_mcp::ipc::Listener, String> {
|
||||
let endpoint = ledger_mcp::ipc::Endpoint::new(run, lease::LEASE_SOCKET)
|
||||
.map_err(|e| format!("can't make the lease socket: {e}"))?;
|
||||
let listener = tokio::net::UnixListener::bind(short.path())
|
||||
.map_err(|e| format!("can't make the lease socket: {}", e.kind()))?;
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
|
||||
.map_err(|e| format!("can't make the lease socket private: {}", e.kind()))?;
|
||||
Ok(listener)
|
||||
ledger_mcp::ipc::Listener::bind(&endpoint)
|
||||
.map_err(|e| format!("can't make the lease socket: {}", e.kind()))
|
||||
}
|
||||
|
||||
/// One connection: its first line, then a lease or one answer
|
||||
/// (lease.rs INVARIANT 1).
|
||||
async fn connection(
|
||||
stream: tokio::net::UnixStream,
|
||||
stream: ledger_mcp::ipc::Accepted,
|
||||
peer_pid: Option<u32>,
|
||||
events: mpsc::UnboundedSender<Event>,
|
||||
) {
|
||||
let (read, mut write) = stream.into_split();
|
||||
let (read, mut write) = ledger_mcp::ipc::split_accepted(stream);
|
||||
let mut reader = tokio::io::BufReader::new(read.take(lease::MAX_LINE as u64 + 1));
|
||||
let mut first = String::new();
|
||||
let answer = |msg: HostMsg| lease::line(&msg);
|
||||
@@ -1019,13 +1050,31 @@ mod tests {
|
||||
"123456"
|
||||
);
|
||||
assert_eq!(std::fs::read_to_string(&log).unwrap(), "", "a new log");
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
assert_eq!(
|
||||
std::fs::metadata(&log).unwrap().permissions().mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
assert_eq!(
|
||||
std::fs::metadata(&log).unwrap().permissions().mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn a_folder_the_host_can_write_isnt_a_sandbox() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert_eq!(confined(dir.path()), None);
|
||||
assert_eq!(
|
||||
std::fs::read_dir(dir.path()).unwrap().count(),
|
||||
0,
|
||||
"its probe went"
|
||||
);
|
||||
// A folder that isn't there: no sign of a sandbox.
|
||||
assert_eq!(confined(&dir.path().join("gone")), None);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_folder_the_host_can_write_isnt_a_sandbox() {
|
||||
let seccomp = std::fs::read_to_string("/proc/self/status")
|
||||
@@ -1061,6 +1110,24 @@ mod tests {
|
||||
assert_eq!(confined(&dir.path().join("gone")), None);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn only_a_file_has_an_identity_and_a_replaced_one_another() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("wanderbooks.exe");
|
||||
std::fs::write(&file, "x").unwrap();
|
||||
let first = identity_of(&file).expect("a file");
|
||||
// Replaced by another file (as an update does): another identity.
|
||||
let next = dir.path().join("next.exe");
|
||||
std::fs::write(&next, "yy").unwrap();
|
||||
std::fs::rename(&next, &file).unwrap();
|
||||
let second = identity_of(&file).unwrap();
|
||||
assert_ne!(first, second);
|
||||
assert_eq!(identity_of(dir.path()), None, "a folder isn't one");
|
||||
assert_eq!(identity_of(&dir.path().join("gone")), None);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn only_a_program_file_has_an_identity() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
Reference in new issue
Block a user