shep_core/transport.rs
1//! The control transport: one address, two implementations
2//!
3//! shep's control plane is a length-delimited framed byte stream between a
4//! client and the shepherd. [`protocol`](crate::protocol) owns what travels
5//! over it; this module owns what carries it, and it is the only place in
6//! the workspace that names an OS transport type.
7//!
8//! On unix that carrier is an `AF_UNIX` socket. On Windows it is a named
9//! pipe. Both are byte streams with the same framing, so everything above
10//! this module — the codec, the handshake, the actor, every RPC verb — is
11//! identical on both platforms and carries no `cfg` at all. That is the
12//! whole point of the seam: the platform difference is spent once, here.
13//!
14//! # The address
15//!
16//! Both halves take a [`Path`], which [`ShepPaths::socket`](crate::paths::ShepPaths::socket)
17//! resolves per-platform: a real socket file under `$SHEP_HOME/run` on unix,
18//! and the `\\.\pipe\shep-<home>` name on Windows. A pipe name is
19//! path-*shaped* but is not a filesystem path — nothing may take its
20//! `parent()` or ask whether it `exists()`.
21//!
22//! # What differs, and why it is not hidden
23//!
24//! A `UnixListener` accepts repeatedly from one descriptor. A named pipe
25//! server instance is *consumed* by the client that connects to it, so the
26//! server must create a fresh instance for the next caller. [`Listener`]
27//! absorbs that difference behind one `accept()`, which is why it takes
28//! `&mut self` where a bare `UnixListener::accept` needs only `&self`.
29//!
30//! # Security
31//!
32//! The unix tier's primary access control is the `0700` on `$SHEP_HOME/run`
33//! — a peer that cannot traverse the directory cannot reach the socket at
34//! all — with the same-uid `peer_cred` check behind it as a second layer.
35//! `shep-daemon`'s `RpcServer` carries the canonical writeup.
36//!
37//! **Windows answers the same question with the pipe's own ACL, and the
38//! shape of the answer is different enough to state plainly.** A named pipe
39//! created with a default security descriptor grants full control to the
40//! creating user, `LocalSystem` and `Administrators`, and grants *read*
41//! access to `Everyone`. It does not grant write access to `Everyone`, and
42//! that asymmetry is what makes the posture defensible: a client must open
43//! the pipe for **both** read and write to speak this protocol at all,
44//! because the daemon sends nothing before it has received a `Hello`. A
45//! foreign local user's open for write is refused by the OS at `CreateFile`
46//! time — fail-closed, before a single byte reaches shep's own code — and
47//! an open for read alone yields a connection the daemon never writes to.
48//!
49//! Two consequences are worth naming rather than leaving to be discovered:
50//!
51//! - There is **no post-accept peer check on Windows**, and none is needed
52//! for the same-user question, because the OS already answered it at open
53//! time. The unix tier's two layers collapse into one that is enforced
54//! earlier. Establishing the *identity* of an already-admitted peer would
55//! need `ImpersonateNamedPipeClient` and a token-SID comparison, which is
56//! raw FFI this crate's `#![forbid(unsafe_code)]` does not permit; it is
57//! not built because it would be a second answer to a settled question,
58//! not because it was overlooked.
59//! - Administrators can reach the pipe. So can they reach a `0700`
60//! directory, and shep's unix writeup already lists root as an explicit
61//! non-goal, so this is parity rather than a regression.
62//!
63//! [`Listener::bind`] additionally sets `reject_remote_clients`, so the pipe
64//! is unreachable over SMB from another machine. Tokio defaults it on; it is
65//! set explicitly here because it is load-bearing and a default that matters
66//! should be visible at the call site.
67
68use std::io;
69use std::path::Path;
70
71/// How long a contended connect waits before retrying.
72///
73/// Windows only, and it is not a timeout — the caller's own budget bounds
74/// the loop (see [`connect`]). Short enough that a server between instances
75/// is not noticeably slower to reach than one already waiting.
76#[cfg(windows)]
77const PIPE_BUSY_RETRY: std::time::Duration = std::time::Duration::from_millis(20);
78
79/// Windows' `ERROR_PIPE_BUSY`: the pipe exists but every server instance is
80/// already spoken for. Transient by definition — the server creates the next
81/// instance immediately after accepting — so it is retried, never surfaced.
82///
83/// Hardcoded rather than pulled from `windows-sys`: this crate has no
84/// Windows-only dependency, and one stable, well-known error code does not
85/// earn it one. The same reasoning [`kv`](crate::kv) applies to
86/// `ERROR_SHARING_VIOLATION`.
87#[cfg(windows)]
88const ERROR_PIPE_BUSY: i32 = 231;
89
90/// The connected stream a **client** holds.
91///
92/// A concrete type alias rather than a `Box<dyn AsyncRead + AsyncWrite>`:
93/// there is exactly one implementation per platform, chosen at compile time,
94/// so a trait object would cost a vtable and an allocation to express a
95/// choice that was already made.
96#[cfg(unix)]
97pub type ClientStream = tokio::net::UnixStream;
98/// The connected stream a **client** holds.
99#[cfg(windows)]
100pub type ClientStream = tokio::net::windows::named_pipe::NamedPipeClient;
101
102/// The connected stream the **daemon** holds for one accepted peer.
103///
104/// The same type as [`ClientStream`] on unix, where a socketpair's two ends
105/// are indistinguishable; a distinct type on Windows, where the server end
106/// of a pipe is its own type. Keeping them separately named means neither
107/// side's code has to know which case it is in.
108#[cfg(unix)]
109pub type ServerStream = tokio::net::UnixStream;
110/// The connected stream the **daemon** holds for one accepted peer.
111#[cfg(windows)]
112pub type ServerStream = tokio::net::windows::named_pipe::NamedPipeServer;
113
114/// The reading half of a [`ServerStream`], after [`split`].
115pub type ServerReadHalf = tokio::io::ReadHalf<ServerStream>;
116/// The writing half of a [`ServerStream`], after [`split`].
117pub type ServerWriteHalf = tokio::io::WriteHalf<ServerStream>;
118
119/// Splits an accepted connection into halves that can be owned by two tasks.
120///
121/// The daemon reads requests on one task and writes replies (and pushed bus
122/// events) on another, so the two halves must be separately owned and
123/// `'static`.
124///
125/// [`tokio::io::split`] rather than `UnixStream::into_split`, which exists
126/// only on unix. The generic split coordinates the two halves through an
127/// internal lock where `into_split` needs none, so this trades a small,
128/// uncontended synchronisation cost on every frame for one code path on both
129/// platforms. That is the right trade here and would not be everywhere: this
130/// is a control plane carrying operator RPC — a handful of frames per
131/// command — not a data path. Do not copy the reasoning to one.
132#[must_use]
133pub fn split(stream: ServerStream) -> (ServerReadHalf, ServerWriteHalf) {
134 tokio::io::split(stream)
135}
136
137/// A connected `(daemon side, client side)` pair over the real platform
138/// transport.
139///
140/// For tests that need a live connection without standing up a daemon. It is
141/// a **real** transport on both platforms, not an in-memory duplex: on unix a
142/// socketpair, on Windows an actual named pipe with a unique name. That
143/// matters, because the thing most worth testing at this layer is behaviour
144/// that an in-memory pipe would not reproduce — a peer closing mid-frame, a
145/// half-open connection, the exact error a dead peer's write produces.
146///
147/// The Windows arm picks its own name rather than taking one, so callers
148/// need no address and no tempdir; process id plus a monotonic counter keeps
149/// concurrent tests off each other in a namespace that is machine-global.
150///
151/// # Errors
152///
153/// Whatever the OS says while creating or connecting the pair.
154pub async fn connected_pair() -> io::Result<(ServerStream, ClientStream)> {
155 #[cfg(unix)]
156 {
157 tokio::net::UnixStream::pair()
158 }
159 #[cfg(windows)]
160 {
161 use core::sync::atomic::{AtomicU64, Ordering};
162 static NEXT: AtomicU64 = AtomicU64::new(0);
163
164 let name = std::path::PathBuf::from(format!(
165 r"\\.\pipe\shep-pair-{}-{}",
166 std::process::id(),
167 NEXT.fetch_add(1, Ordering::Relaxed)
168 ));
169 let mut listener = Listener::bind(&name)?;
170 // Concurrently, not in sequence: `accept` does not resolve until a
171 // client attaches, and `connect` cannot attach until the server is
172 // waiting, so awaiting either one alone would deadlock.
173 tokio::try_join!(listener.accept(), connect(&name))
174 }
175}
176
177/// Dials the shepherd at `addr`.
178///
179/// # Errors
180///
181/// Whatever the OS says. The one case handled rather than returned is
182/// Windows' `ERROR_PIPE_BUSY`, which means the pipe exists but every server
183/// instance is currently serving someone: that is transient and is retried.
184///
185/// **This loop is deliberately unbounded, and is safe only because every
186/// caller bounds it.** `shep-client`'s `Connection::open` wraps the whole
187/// connect-plus-handshake in one `tokio::time::timeout`, so a pipe that
188/// stays busy forever surfaces as that layer's `HandshakeTimeout` — the same
189/// error a unix socket that is bound but never accepted from produces, which
190/// is the behaviour the two platforms should share. A bound here as well
191/// would be a second, quieter deadline competing with it.
192pub async fn connect(addr: &Path) -> io::Result<ClientStream> {
193 #[cfg(unix)]
194 {
195 tokio::net::UnixStream::connect(addr).await
196 }
197 #[cfg(windows)]
198 {
199 use tokio::net::windows::named_pipe::ClientOptions;
200 loop {
201 match ClientOptions::new().open(addr) {
202 Ok(client) => return Ok(client),
203 Err(err) if err.raw_os_error() == Some(ERROR_PIPE_BUSY) => {
204 tokio::time::sleep(PIPE_BUSY_RETRY).await;
205 }
206 Err(err) => return Err(err),
207 }
208 }
209 }
210}
211
212/// The listening half of the control transport.
213///
214/// Holds a bound `UnixListener` on unix, and on Windows the *next* idle
215/// named pipe server instance — see [`Self::accept`] for why that is one
216/// instance rather than a listener.
217#[derive(Debug)]
218pub struct Listener {
219 #[cfg(unix)]
220 listener: tokio::net::UnixListener,
221 /// The idle instance the next client will connect to, taken on every
222 /// accept and replaced with a fresh one.
223 ///
224 /// `None` only between an accept handing out its connection and the
225 /// replacement being created, and across an accept whose replacement
226 /// could not be created at all. [`Self::accept`] makes one when it
227 /// finds the slot empty, which is what keeps a single failed `create`
228 /// from ending the daemon's ability to serve anyone.
229 #[cfg(windows)]
230 server: Option<tokio::net::windows::named_pipe::NamedPipeServer>,
231 /// The pipe name, kept so each replacement instance can be created on
232 /// the same name.
233 #[cfg(windows)]
234 addr: std::ffi::OsString,
235}
236
237impl Listener {
238 /// Binds the control transport at `addr`.
239 ///
240 /// # Errors
241 ///
242 /// Whatever the OS says: the socket path is unwritable or already bound
243 /// on unix, the pipe name is already owned on Windows.
244 ///
245 /// # Single instance
246 ///
247 /// The Windows arm passes `first_pipe_instance(true)`, which makes this
248 /// call itself the daemon's mutual exclusion: a second shepherd trying
249 /// the same `$SHEP_HOME` fails here with `ERROR_ACCESS_DENIED` rather
250 /// than quietly creating a second instance of the same pipe and stealing
251 /// half the connections. That is a stronger guarantee than the unix
252 /// arm's, where binding over a stale socket file is possible and the
253 /// pidfile lock is what actually excludes a second daemon — and it is
254 /// free, which is why it is used rather than reproducing the pidfile
255 /// dance on a platform that does not need it.
256 ///
257 /// It also removes the stale-socket problem entirely instead of solving
258 /// it: a pipe has no directory entry, so a daemon that died leaves
259 /// nothing behind to recover from. There is no Windows equivalent of the
260 /// "connect, fail, unlink, rebind" sequence, because there is nothing to
261 /// unlink.
262 pub fn bind(addr: &Path) -> io::Result<Self> {
263 #[cfg(unix)]
264 {
265 Ok(Self {
266 listener: tokio::net::UnixListener::bind(addr)?,
267 })
268 }
269 #[cfg(windows)]
270 {
271 use tokio::net::windows::named_pipe::ServerOptions;
272 let server = ServerOptions::new()
273 .first_pipe_instance(true)
274 .reject_remote_clients(true)
275 .create(addr)?;
276 Ok(Self {
277 server: Some(server),
278 addr: addr.as_os_str().to_os_string(),
279 })
280 }
281 }
282
283 /// Wraps a socket this process was handed rather than one it bound.
284 ///
285 /// The successor's half of a daemon handover. The control socket is one
286 /// of the descriptors an outgoing shepherd passes across its `execve`,
287 /// so the image that takes over adopts the listener instead of binding
288 /// the address again: a rebind would race the predecessor's socket file
289 /// and lose whatever connection a client had already made.
290 ///
291 /// Unix only, and the whole handover is. Windows has no `execve`, and
292 /// its arm of [`Self::bind`] makes the bind itself the daemon's mutual
293 /// exclusion, so a second image could not create the pipe to hand on in
294 /// the first place.
295 #[cfg(unix)]
296 #[must_use]
297 pub fn from_unix_listener(listener: tokio::net::UnixListener) -> Self {
298 Self { listener }
299 }
300
301 /// The descriptor this listener is bound on.
302 ///
303 /// The predecessor's half of a daemon handover, and the counterpart to
304 /// [`Self::from_unix_listener`]: an outgoing shepherd has to name this
305 /// number in the blob it hands on, since a descriptor number is only
306 /// meaningful in the process that owns it and the successor adopts it by
307 /// number. Borrowed, never owned: closing it would close the control
308 /// socket out from under a daemon that is still serving.
309 ///
310 /// Unix only, as the whole handover is.
311 #[cfg(unix)]
312 #[must_use]
313 pub fn as_raw_fd(&self) -> std::os::fd::RawFd {
314 use std::os::fd::AsRawFd as _;
315 self.listener.as_raw_fd()
316 }
317
318 /// Waits for the next peer and returns its connected stream.
319 ///
320 /// # Errors
321 ///
322 /// Whatever the OS says. A transient failure is the caller's to log and
323 /// continue from — one bad accept must not end a daemon.
324 ///
325 /// # Cancellation safety
326 ///
327 /// Safe on both platforms, which the daemon's accept loop depends on: it
328 /// `select!`s this against a shutdown watch, and a cancelled accept must
329 /// not drop a peer that was mid-connect. `UnixListener::accept` and
330 /// `NamedPipeServer::connect` are both documented cancel-safe, and the
331 /// Windows arm's instance swap happens only *after* `connect` has
332 /// resolved, so a cancellation cannot leave this holding an instance
333 /// that a client has already been handed.
334 ///
335 /// # Why `&mut self`
336 ///
337 /// A named pipe server instance is consumed by whoever connects to it:
338 /// once a client is attached, that instance *is* the connection and can
339 /// never accept again. So accepting means handing out the instance we
340 /// were holding and creating the next one, which needs exclusive access.
341 /// The unix arm needs only `&self` and takes `&mut self` anyway, so both
342 /// platforms present one signature.
343 pub async fn accept(&mut self) -> io::Result<ServerStream> {
344 #[cfg(unix)]
345 {
346 let (stream, _addr) = self.listener.accept().await?;
347 Ok(stream)
348 }
349 #[cfg(windows)]
350 {
351 use tokio::net::windows::named_pipe::ServerOptions;
352 // The slot is empty only if a previous accept could not create
353 // a replacement. Make one now rather than at that failure, so a
354 // transient `create` error costs one accept instead of the
355 // daemon's whole ability to serve.
356 if self.server.is_none() {
357 self.server = Some(
358 ServerOptions::new()
359 .reject_remote_clients(true)
360 .create(&self.addr)?,
361 );
362 }
363 // Resolves when a client attaches to the instance we hold.
364 let Some(server) = self.server.as_ref() else {
365 unreachable!("the slot was just filled")
366 };
367 server.connect().await?;
368 // Out of the slot BEFORE anything that can fail. Once a client
369 // has attached, this instance IS that connection and can never
370 // accept again, so leaving it in place would mean the next
371 // accept calling `connect` on a connected instance: Windows
372 // answers that with ERROR_PIPE_CONNECTED or ERROR_NO_DATA
373 // rather than waiting, and the daemon's accept loop, which
374 // logs an error and carries on, would spin on it forever
375 // instead of serving anyone.
376 let connected = self
377 .server
378 .take()
379 .unwrap_or_else(|| unreachable!("the slot was just filled"));
380 // `first_pipe_instance` is deliberately NOT set here: it is set
381 // once, at `bind`, and setting it again would refuse to create
382 // the very instance this listener already owns the name for.
383 //
384 // A failure here leaves the slot empty and the peer connected,
385 // which is the right way round. The alternative, `?` before the
386 // handoff, drops a peer that has already attached AND leaves a
387 // connected instance in the slot for the next accept to trip
388 // over. The error is not lost: the next accept re-creates and
389 // reports it, having handed this peer over first.
390 self.server = ServerOptions::new()
391 .reject_remote_clients(true)
392 .create(&self.addr)
393 .ok();
394 Ok(connected)
395 }
396 }
397}
398
399#[cfg(test)]
400mod tests {
401 use super::*;
402 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
403
404 /// A control address that is valid on the platform running the test:
405 /// a file inside `dir` on unix, a uniquely-named pipe on Windows (where
406 /// `dir` is irrelevant, since a pipe name is not a filesystem path).
407 ///
408 /// `tag` keeps concurrently running tests off each other's address —
409 /// the pipe namespace is machine-global, so two tests using one name
410 /// would contend for real.
411 fn address(dir: &Path, tag: &str) -> std::path::PathBuf {
412 #[cfg(unix)]
413 {
414 let _ = tag;
415 dir.join("shep.sock")
416 }
417 #[cfg(windows)]
418 {
419 let _ = dir;
420 std::path::PathBuf::from(format!(
421 r"\\.\pipe\shep-transport-test-{tag}-{}",
422 std::process::id()
423 ))
424 }
425 }
426
427 /// fails if a listener rebuilt around an inherited socket cannot serve.
428 ///
429 /// The successor half of a daemon handover: the control socket is one
430 /// descriptor the outgoing shepherd hands on, so its replacement binds
431 /// nothing and wraps what it was given. A `bind` here instead would
432 /// meet the socket file the predecessor left behind, and the connection
433 /// a client had already made to it would be dropped on the floor.
434 #[cfg(unix)]
435 #[tokio::test]
436 async fn a_listener_built_around_an_inherited_socket_still_accepts() {
437 let dir = tempfile::tempdir().unwrap();
438 let addr = address(dir.path(), "adopted");
439 let inherited = tokio::net::UnixListener::bind(&addr).unwrap();
440
441 let mut listener = Listener::from_unix_listener(inherited);
442 let client = tokio::spawn(async move {
443 let mut stream = connect(&addr).await.unwrap();
444 stream.write_all(b"still here\n").await.unwrap();
445 });
446
447 // Every await bounded, as `dialing_an_address_with_no_listener_
448 // fails_rather_than_hanging` above already does. An adopted listener
449 // that stopped accepting would otherwise hang this case, and a hang
450 // stops the whole test binary rather than failing one test, so CI
451 // times out with no assertion to point at.
452 let bound = std::time::Duration::from_secs(10);
453 let mut served = tokio::time::timeout(bound, listener.accept())
454 .await
455 .expect("an adopted listener must accept")
456 .unwrap();
457 let mut said = [0_u8; 11];
458 tokio::time::timeout(bound, served.read_exact(&mut said))
459 .await
460 .expect("the bytes the client wrote must arrive")
461 .unwrap();
462 assert_eq!(&said, b"still here\n");
463 tokio::time::timeout(bound, client)
464 .await
465 .expect("the client task must finish")
466 .unwrap();
467 }
468
469 /// fails if the transport cannot carry bytes both ways on this platform.
470 /// The most basic thing this module claims, and the one that would break
471 /// silently if `ClientStream` and `ServerStream` were ever mismatched.
472 #[tokio::test]
473 async fn a_client_and_the_daemon_exchange_bytes_over_the_platform_transport() {
474 let dir = tempfile::tempdir().unwrap();
475 let addr = address(dir.path(), "roundtrip");
476 let mut listener = Listener::bind(&addr).unwrap();
477
478 let server = tokio::spawn(async move {
479 let mut stream = listener.accept().await.unwrap();
480 let mut buf = [0u8; 5];
481 stream.read_exact(&mut buf).await.unwrap();
482 stream.write_all(b"world").await.unwrap();
483 stream.flush().await.unwrap();
484 buf
485 });
486
487 let mut client = connect(&addr).await.unwrap();
488 client.write_all(b"hello").await.unwrap();
489 client.flush().await.unwrap();
490 let mut reply = [0u8; 5];
491 client.read_exact(&mut reply).await.unwrap();
492
493 assert_eq!(&server.await.unwrap(), b"hello");
494 assert_eq!(&reply, b"world");
495 }
496
497 /// fails if a second connection cannot be served after the first.
498 ///
499 /// This is the whole reason [`Listener::accept`] takes `&mut self`: a
500 /// named pipe server instance is consumed by its client, so an
501 /// implementation that forgot to create a replacement would serve
502 /// exactly one caller and then hang forever. On unix this is trivially
503 /// true and the test costs nothing; on Windows it is the load-bearing
504 /// assertion of this module.
505 #[tokio::test]
506 async fn the_listener_serves_more_than_one_connection() {
507 let dir = tempfile::tempdir().unwrap();
508 let addr = address(dir.path(), "sequential");
509 let mut listener = Listener::bind(&addr).unwrap();
510
511 let server = tokio::spawn(async move {
512 let mut seen = Vec::new();
513 for _ in 0..3 {
514 let mut stream = listener.accept().await.unwrap();
515 let mut byte = [0u8; 1];
516 stream.read_exact(&mut byte).await.unwrap();
517 seen.push(byte[0]);
518 }
519 seen
520 });
521
522 for tag in [1u8, 2, 3] {
523 let mut client = connect(&addr).await.unwrap();
524 client.write_all(&[tag]).await.unwrap();
525 client.flush().await.unwrap();
526 // Dropped here: the next iteration must get a fresh instance.
527 }
528
529 assert_eq!(server.await.unwrap(), vec![1, 2, 3]);
530 }
531
532 /// fails if dialing an address nothing is listening on reports success.
533 ///
534 /// The negative case the connect-retry loop could plausibly swallow: on
535 /// Windows a missing pipe is `ERROR_FILE_NOT_FOUND`, which must be
536 /// returned, while only `ERROR_PIPE_BUSY` is retried. A loop that
537 /// retried both would hang here instead of failing.
538 #[tokio::test]
539 async fn dialing_an_address_with_no_listener_fails_rather_than_hanging() {
540 let dir = tempfile::tempdir().unwrap();
541 let addr = address(dir.path(), "absent");
542
543 let result = tokio::time::timeout(std::time::Duration::from_secs(5), connect(&addr))
544 .await
545 .expect("connect must fail fast, not hang, when nothing is listening");
546
547 assert!(result.is_err(), "no listener must not read as a connection");
548 }
549
550 /// fails if two shepherds can own one control address at once.
551 ///
552 /// On Windows this is `first_pipe_instance(true)` doing the daemon's
553 /// mutual exclusion; a second `create` on the same name is refused by
554 /// the OS. On unix `bind` refuses an address already bound. Both
555 /// platforms must refuse, for the same operator-visible reason: two
556 /// daemons on one `$SHEP_HOME` would split the flock between them.
557 #[tokio::test]
558 async fn a_second_bind_on_the_same_address_is_refused() {
559 let dir = tempfile::tempdir().unwrap();
560 let addr = address(dir.path(), "exclusive");
561 let _first = Listener::bind(&addr).unwrap();
562
563 assert!(
564 Listener::bind(&addr).is_err(),
565 "a second daemon must not be able to bind the same control address"
566 );
567 }
568}