Skip to main content

inferd_daemon/
endpoint.rs

1//! IPC listener abstractions for inferd-daemon.
2//!
3//! v0.1 ships:
4//! - **Unix domain socket** (Unix only) — the default inference transport.
5//! - **Loopback TCP** — opt-in fallback for container / WSL scenarios; the
6//!   default port is `127.0.0.1:47321`.
7//!
8//! Windows named pipe support is deferred to M4. This is fine for the M1
9//! exit criterion, which uses TCP for cross-platform integration testing.
10//!
11//! ## Ready gating (THREAT_MODEL F-13)
12//!
13//! Listeners are created by `bind_*` functions only — they never bind in the
14//! constructor. The lifecycle calls `bind_*` *after* the configured backend
15//! reports `ready()`, so the OS-level socket simply does not exist until
16//! the daemon is willing to accept work.
17
18use std::io;
19use std::net::SocketAddr;
20use std::path::Path;
21use tokio::io::{AsyncRead, AsyncWrite};
22use tokio::net::{TcpListener, TcpStream};
23
24/// Default loopback port for the optional TCP transport.
25pub const DEFAULT_TCP_ADDR: &str = "127.0.0.1:47321";
26
27/// Default admin endpoint per platform, per `docs/protocol-v1.md`
28/// §"Admin endpoint".
29///
30/// On Linux the resolution chain is:
31/// 1. `$XDG_RUNTIME_DIR/inferd/admin.sock` (set by `systemd-logind`
32///    on session start; the per-user equivalent of `/run/<svc>/`).
33/// 2. `$HOME/.inferd/run/admin.sock` for sessions without logind
34///    (containers, ssh without a real login session).
35/// 3. `/tmp/inferd-<uid>/admin.sock` as a last resort.
36///
37/// Historically inferd defaulted to `/run/inferd/admin.sock`. That
38/// path is only writable by root and so was incompatible with
39/// `systemd --user` units; the chain above matches the path the
40/// systemd unit declares via `RuntimeDirectory=inferd`.
41pub fn default_admin_addr() -> std::path::PathBuf {
42    #[cfg(target_os = "linux")]
43    {
44        linux_runtime_path("admin.sock")
45    }
46    #[cfg(target_os = "macos")]
47    {
48        let mut p = std::env::temp_dir();
49        p.push("inferd");
50        p.push("admin.sock");
51        p
52    }
53    #[cfg(windows)]
54    {
55        std::path::PathBuf::from(DEFAULT_ADMIN_PIPE_PATH)
56    }
57    #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
58    {
59        std::path::PathBuf::from("/tmp/inferd/admin.sock")
60    }
61}
62
63/// Default v2 inference endpoint per ADR 0015 §Endpoints.
64///
65/// - Linux: `${XDG_RUNTIME_DIR}/inferd/infer.v2.sock` (with the
66///   same fallback chain as v1)
67/// - macOS: `${TMPDIR}/inferd/infer.v2.sock`
68/// - Windows: `\\.\pipe\inferd-infer-v2`
69pub fn default_v2_addr() -> std::path::PathBuf {
70    #[cfg(target_os = "linux")]
71    {
72        linux_runtime_path("infer.v2.sock")
73    }
74    #[cfg(target_os = "macos")]
75    {
76        let mut p = std::env::temp_dir();
77        p.push("inferd");
78        p.push("infer.v2.sock");
79        p
80    }
81    #[cfg(windows)]
82    {
83        std::path::PathBuf::from(DEFAULT_PIPE_V2_PATH)
84    }
85    #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
86    {
87        std::path::PathBuf::from("/tmp/inferd/infer.v2.sock")
88    }
89}
90
91/// Default embed inference endpoint per ADR 0017 §Endpoints.
92///
93/// - Linux: `${XDG_RUNTIME_DIR}/inferd/infer.embed.sock` (same
94///   fallback chain as v1 / v2)
95/// - macOS: `${TMPDIR}/inferd/infer.embed.sock`
96/// - Windows: `\\.\pipe\inferd-infer-embed`
97pub fn default_embed_addr() -> std::path::PathBuf {
98    #[cfg(target_os = "linux")]
99    {
100        linux_runtime_path("infer.embed.sock")
101    }
102    #[cfg(target_os = "macos")]
103    {
104        let mut p = std::env::temp_dir();
105        p.push("inferd");
106        p.push("infer.embed.sock");
107        p
108    }
109    #[cfg(windows)]
110    {
111        std::path::PathBuf::from(DEFAULT_PIPE_EMBED_PATH)
112    }
113    #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
114    {
115        std::path::PathBuf::from("/tmp/inferd/infer.embed.sock")
116    }
117}
118
119/// Resolve a Linux runtime-dir path with the fallback chain
120/// documented on `default_admin_addr`. `leaf` is the basename to
121/// append (e.g. `admin.sock`, `infer.sock`, `inferd.lock`).
122#[cfg(target_os = "linux")]
123pub fn linux_runtime_path(leaf: &str) -> std::path::PathBuf {
124    if let Some(xdg) = std::env::var_os("XDG_RUNTIME_DIR") {
125        let mut p = std::path::PathBuf::from(xdg);
126        if !p.as_os_str().is_empty() {
127            p.push("inferd");
128            p.push(leaf);
129            return p;
130        }
131    }
132    if let Some(home) = std::env::var_os("HOME") {
133        let mut p = std::path::PathBuf::from(home);
134        if !p.as_os_str().is_empty() {
135            p.push(".inferd");
136            p.push("run");
137            p.push(leaf);
138            return p;
139        }
140    }
141    // Last resort: `/tmp/inferd-<uid>/<leaf>` — `<uid>` keeps
142    // multi-user hosts from colliding on a shared /tmp.
143    let uid = nix::unistd::Uid::current().as_raw();
144    std::path::PathBuf::from(format!("/tmp/inferd-{uid}/{leaf}"))
145}
146
147/// Trait abstracting an accepted connection so the lifecycle can speak to
148/// either a Unix-socket stream or a TCP stream uniformly.
149pub trait Connection: AsyncRead + AsyncWrite + Unpin + Send {
150    /// Stable string identifying the transport ("unix"/"tcp"). Used for
151    /// activity-log attribution; not echoed on the wire.
152    fn transport(&self) -> &'static str;
153}
154
155impl Connection for TcpStream {
156    fn transport(&self) -> &'static str {
157        "tcp"
158    }
159}
160
161#[cfg(unix)]
162impl Connection for tokio::net::UnixStream {
163    fn transport(&self) -> &'static str {
164        "unix"
165    }
166}
167
168/// Bind a loopback TCP listener at `addr`.
169///
170/// `addr` must parse as a `SocketAddr`. By convention the daemon binds
171/// `127.0.0.1` only — operators wanting a different bind have to opt in
172/// explicitly via configuration. We do not attempt to enforce loopback-only
173/// here because that's a config-layer decision; the threat model documents
174/// the consequence (F-8) when an operator chooses non-loopback.
175pub async fn bind_tcp(addr: &str) -> io::Result<TcpListener> {
176    let parsed: SocketAddr = addr
177        .parse()
178        .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, format!("bad tcp addr: {e}")))?;
179    TcpListener::bind(parsed).await
180}
181
182/// Bind a Unix domain socket at `path` with mode `0660` and the given group
183/// (Unix only).
184///
185/// On Windows this returns `Err(Unsupported)` — UDS support is M4 (named
186/// pipe).
187#[cfg(unix)]
188pub async fn bind_uds(path: &Path, group: Option<&str>) -> io::Result<tokio::net::UnixListener> {
189    use std::os::unix::fs::PermissionsExt;
190    // Remove a stale socket file from a previous run before binding. Stat
191    // first to refuse if it's a symlink (hardening; F-2 is for the lock
192    // path, but the same hygiene applies to listener paths).
193    if let Ok(meta) = std::fs::symlink_metadata(path) {
194        if meta.file_type().is_symlink() {
195            return Err(io::Error::new(
196                io::ErrorKind::InvalidInput,
197                format!("uds path is a symlink (refused): {}", path.display()),
198            ));
199        }
200        std::fs::remove_file(path)?;
201    }
202    let listener = tokio::net::UnixListener::bind(path)?;
203    let mut perms = std::fs::metadata(path)?.permissions();
204    perms.set_mode(0o660);
205    std::fs::set_permissions(path, perms)?;
206
207    if let Some(group_name) = group {
208        chown_to_group(path, group_name)?;
209    }
210    Ok(listener)
211}
212
213/// Bind the *admin* Unix domain socket at `path` with mode `0600`
214/// (Unix only). Stricter than `bind_uds`: the admin socket is
215/// daemon-uid only — no group-shared inference, no operator group.
216/// Per ADR 0009 + `docs/protocol-v1.md` §"Admin endpoint".
217#[cfg(unix)]
218pub async fn bind_admin_uds(path: &Path) -> io::Result<tokio::net::UnixListener> {
219    use std::os::unix::fs::PermissionsExt;
220    if let Ok(meta) = std::fs::symlink_metadata(path) {
221        if meta.file_type().is_symlink() {
222            return Err(io::Error::new(
223                io::ErrorKind::InvalidInput,
224                format!("admin uds path is a symlink (refused): {}", path.display()),
225            ));
226        }
227        std::fs::remove_file(path)?;
228    }
229    if let Some(parent) = path.parent()
230        && !parent.as_os_str().is_empty()
231    {
232        std::fs::create_dir_all(parent)?;
233    }
234    let listener = tokio::net::UnixListener::bind(path)?;
235    let mut perms = std::fs::metadata(path)?.permissions();
236    perms.set_mode(0o600);
237    std::fs::set_permissions(path, perms)?;
238    Ok(listener)
239}
240
241/// Non-Unix stub for `bind_admin_uds`. Use [`bind_admin_pipe`] on
242/// Windows.
243#[cfg(not(unix))]
244pub async fn bind_admin_uds(_path: &Path) -> io::Result<()> {
245    Err(io::Error::new(
246        io::ErrorKind::Unsupported,
247        "Unix domain sockets are not supported on this platform; use bind_admin_pipe",
248    ))
249}
250
251/// Bind the *admin* Windows named pipe at `path` (Windows only).
252///
253/// Applies the same SDDL DACL as the inference pipe — current user
254/// SID only — via `windows_security::PipeSecurityDescriptor`.
255#[cfg(windows)]
256#[allow(unsafe_code)] // Scoped: tokio's create_with_security_attributes_raw is unsafe.
257pub fn bind_admin_pipe(
258    path: &str,
259    first: bool,
260) -> io::Result<tokio::net::windows::named_pipe::NamedPipeServer> {
261    use crate::windows_security::PipeSecurityDescriptor;
262    use tokio::net::windows::named_pipe::ServerOptions;
263
264    let mut sd = PipeSecurityDescriptor::current_user_only()?;
265    let mut opts = ServerOptions::new();
266    opts.first_pipe_instance(first);
267    // SAFETY: `sd.as_attrs_ptr()` is a stable pointer into `sd`'s
268    // own storage; `sd` lives across the create call. Windows copies
269    // the descriptor into the kernel object during CreateNamedPipe,
270    // so dropping `sd` after this returns is fine.
271    let server = unsafe { opts.create_with_security_attributes_raw(path, sd.as_attrs_ptr()) }?;
272    drop(sd);
273    Ok(server)
274}
275
276/// Stub for non-Unix platforms; always returns `Unsupported`. On Windows,
277/// callers should use [`bind_named_pipe`] instead.
278#[cfg(not(unix))]
279pub async fn bind_uds(_path: &Path, _group: Option<&str>) -> io::Result<()> {
280    Err(io::Error::new(
281        io::ErrorKind::Unsupported,
282        "Unix domain sockets are not supported on this platform; use bind_named_pipe or TCP",
283    ))
284}
285
286/// Default Windows named-pipe path for the inference endpoint.
287#[cfg(windows)]
288pub const DEFAULT_PIPE_PATH: &str = r"\\.\pipe\inferd-infer";
289
290/// Default Windows named-pipe path for the v2 inference endpoint
291/// per ADR 0015 §Endpoints. Distinct from `DEFAULT_PIPE_PATH` so v1
292/// and v2 can coexist on the same daemon.
293#[cfg(windows)]
294pub const DEFAULT_PIPE_V2_PATH: &str = r"\\.\pipe\inferd-infer-v2";
295
296/// Default Windows named-pipe path for the admin endpoint per
297/// `docs/protocol-v1.md` §"Admin endpoint". Shared between v1 and v2
298/// (ADR 0015 §Endpoints — admin is lifecycle, not request-shape).
299#[cfg(windows)]
300pub const DEFAULT_ADMIN_PIPE_PATH: &str = r"\\.\pipe\inferd-admin";
301
302/// Default Windows named-pipe path for the embed inference endpoint
303/// per ADR 0017 §Endpoints. Distinct from `DEFAULT_PIPE_PATH` /
304/// `DEFAULT_PIPE_V2_PATH` so v1, v2 and embed can coexist on the
305/// same daemon.
306#[cfg(windows)]
307pub const DEFAULT_PIPE_EMBED_PATH: &str = r"\\.\pipe\inferd-infer-embed";
308
309/// Bind a Windows named-pipe **server endpoint** at `path`.
310///
311/// Returns a single connected `NamedPipeServer` per accept; the caller
312/// is expected to call `bind_named_pipe` again to open the next instance
313/// (the standard Windows multi-instance pattern). `lifecycle::serve_named_pipe`
314/// owns that loop.
315///
316/// **Security posture (THREAT_MODEL F-7):** the pipe is created with
317/// an explicit SDDL DACL that grants `GENERIC_ALL` to the current
318/// process's user SID and nobody else (protected DACL, no inheritance).
319/// Anyone not the daemon's own user is denied at the kernel-object
320/// level. See `windows_security::PipeSecurityDescriptor` for the
321/// descriptor construction.
322///
323/// `first` controls whether the returned server is the very first
324/// instance for `path` (which sets `FILE_FLAG_FIRST_PIPE_INSTANCE` to
325/// reject if another process is already serving the same name). The
326/// accept loop calls `bind_named_pipe(path, false)` for subsequent
327/// instances.
328#[cfg(windows)]
329#[allow(unsafe_code)] // Scoped: tokio's create_with_security_attributes_raw is unsafe.
330pub fn bind_named_pipe(
331    path: &str,
332    first: bool,
333) -> io::Result<tokio::net::windows::named_pipe::NamedPipeServer> {
334    use crate::windows_security::PipeSecurityDescriptor;
335    use tokio::net::windows::named_pipe::ServerOptions;
336
337    let mut sd = PipeSecurityDescriptor::current_user_only()?;
338    let mut opts = ServerOptions::new();
339    opts.first_pipe_instance(first);
340    // SAFETY: `sd.as_attrs_ptr()` is a stable pointer into `sd`'s
341    // own storage; `sd` lives across the create call. Windows copies
342    // the descriptor into the kernel object during CreateNamedPipe,
343    // so dropping `sd` after this returns is fine.
344    let server = unsafe { opts.create_with_security_attributes_raw(path, sd.as_attrs_ptr()) }?;
345    drop(sd);
346    Ok(server)
347}
348
349#[cfg(windows)]
350impl Connection for tokio::net::windows::named_pipe::NamedPipeServer {
351    fn transport(&self) -> &'static str {
352        "pipe"
353    }
354}
355
356#[cfg(unix)]
357fn chown_to_group(path: &Path, group_name: &str) -> io::Result<()> {
358    let group = nix::unistd::Group::from_name(group_name)
359        .map_err(|e| io::Error::other(format!("getgrnam: {e}")))?
360        .ok_or_else(|| {
361            io::Error::new(
362                io::ErrorKind::NotFound,
363                format!("group not found: {group_name}"),
364            )
365        })?;
366    nix::unistd::chown(path, None, Some(group.gid))
367        .map_err(|e| io::Error::other(format!("chown: {e}")))
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373    use tokio::io::{AsyncReadExt, AsyncWriteExt};
374
375    #[tokio::test]
376    async fn bind_tcp_accepts_a_connection() {
377        let listener = bind_tcp("127.0.0.1:0").await.unwrap();
378        let addr = listener.local_addr().unwrap();
379
380        let server = tokio::spawn(async move {
381            let (mut sock, _) = listener.accept().await.unwrap();
382            let mut buf = [0u8; 4];
383            sock.read_exact(&mut buf).await.unwrap();
384            assert_eq!(&buf, b"ping");
385            sock.write_all(b"pong").await.unwrap();
386        });
387
388        let mut client = TcpStream::connect(addr).await.unwrap();
389        client.write_all(b"ping").await.unwrap();
390        let mut buf = [0u8; 4];
391        client.read_exact(&mut buf).await.unwrap();
392        assert_eq!(&buf, b"pong");
393        server.await.unwrap();
394    }
395
396    #[tokio::test]
397    async fn bind_tcp_rejects_garbage_addr() {
398        let err = bind_tcp("not-an-addr").await.unwrap_err();
399        assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
400    }
401
402    #[cfg(unix)]
403    #[tokio::test]
404    async fn bind_uds_creates_socket_and_accepts() {
405        use tempfile::tempdir;
406        let dir = tempdir().unwrap();
407        let path = dir.path().join("test.sock");
408        let listener = bind_uds(&path, None).await.unwrap();
409
410        let server = tokio::spawn(async move {
411            let (mut sock, _) = listener.accept().await.unwrap();
412            let mut buf = [0u8; 4];
413            sock.read_exact(&mut buf).await.unwrap();
414            assert_eq!(&buf, b"ping");
415        });
416
417        let mut client = tokio::net::UnixStream::connect(&path).await.unwrap();
418        client.write_all(b"ping").await.unwrap();
419        server.await.unwrap();
420    }
421
422    #[cfg(windows)]
423    #[tokio::test]
424    async fn bind_named_pipe_accepts_a_connection() {
425        use tokio::io::{AsyncReadExt, AsyncWriteExt};
426        use tokio::net::windows::named_pipe::ClientOptions;
427
428        // Use a unique pipe name per test invocation. Process-wide atomic
429        // counter handles parallel tests within the same binary;
430        // PID + timestamp ns spread across independent processes.
431        use std::sync::atomic::{AtomicU64, Ordering};
432        static COUNTER: AtomicU64 = AtomicU64::new(0);
433        let pid = std::process::id();
434        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
435        let ts = std::time::SystemTime::now()
436            .duration_since(std::time::UNIX_EPOCH)
437            .unwrap()
438            .as_nanos();
439        let path = format!(r"\\.\pipe\inferd-endpoint-test-{pid}-{ts}-{n}");
440
441        let server = bind_named_pipe(&path, true).expect("bind named pipe");
442
443        let path_for_server = path.clone();
444        let server_task = tokio::spawn(async move {
445            server.connect().await.expect("server connect");
446            let mut s = server;
447            let mut buf = [0u8; 4];
448            s.read_exact(&mut buf).await.unwrap();
449            assert_eq!(&buf, b"ping");
450            s.write_all(b"pong").await.unwrap();
451            drop(path_for_server);
452        });
453
454        let mut client = ClientOptions::new()
455            .open(&path)
456            .expect("client open named pipe");
457        client.write_all(b"ping").await.unwrap();
458        let mut buf = [0u8; 4];
459        client.read_exact(&mut buf).await.unwrap();
460        assert_eq!(&buf, b"pong");
461        server_task.await.unwrap();
462    }
463
464    #[cfg(unix)]
465    #[tokio::test]
466    async fn bind_uds_refuses_symlink_path() {
467        use tempfile::tempdir;
468        let dir = tempdir().unwrap();
469        let target = dir.path().join("real.sock");
470        std::fs::write(&target, b"").unwrap();
471        let symlink = dir.path().join("link.sock");
472        std::os::unix::fs::symlink(&target, &symlink).unwrap();
473
474        let err = bind_uds(&symlink, None).await.unwrap_err();
475        assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
476    }
477}