Skip to main content

khive_runtime/daemon/
run_daemon.rs

1#![cfg(unix)]
2use super::*;
3
4/// Run the daemon: bind the socket, warm in the background, serve request
5/// frames until SIGTERM/SIGINT.
6///
7/// Fatally acquires its own startup lock, which only protects
8/// cleanup→pid-claim→bind — `dispatcher` has already run migrations and
9/// applied pack schema plans while constructing itself, unguarded. Production
10/// boot must go through [`run_daemon_with_boot_guard`] instead, which extends
11/// the same lock back over construction. This entry point is for callers
12/// (and tests) that build the dispatcher and start serving as one atomic
13/// step with no separate boot-guard window to protect.
14#[cfg(unix)]
15pub async fn run_daemon<D: DaemonDispatch>(dispatcher: D) -> anyhow::Result<()> {
16    let boot_guard = Some(acquire_daemon_boot_guard()?);
17    run_daemon_with_boot_guard_inner(
18        dispatcher,
19        boot_guard,
20        false,
21        DaemonOptions::default(),
22        |_| DaemonStartupReport::default(),
23    )
24    .await
25}
26
27/// Run a real daemon server for an in-process multi-launch test.
28///
29/// Separate production daemon candidates have distinct PIDs, so the boot fence
30/// recognizes a live incumbent and makes later candidates exit. Parallel
31/// test launchers share one OS process and therefore one PID; this explicit
32/// fault-injection entry point preserves the production fence semantics by
33/// allowing a live same-PID incumbent to win. Ordinary daemon startup
34/// continues to treat a same-PID rendezvous as stale, protecting PID-reuse
35/// cleanup behavior.
36///
37/// A losing candidate still follows the ordinary daemon-exit path and cancels
38/// the process-wide component shutdown token. Callers must therefore use a
39/// component-free dispatcher; this seam validates socket/PID ownership, not
40/// multi-candidate component lifecycle.
41#[cfg(all(unix, any(test, feature = "fault-injection")))]
42#[doc(hidden)]
43pub async fn run_daemon_in_process_test<D: DaemonDispatch>(dispatcher: D) -> anyhow::Result<()> {
44    let boot_guard = Some(acquire_daemon_boot_guard()?);
45    run_daemon_with_boot_guard_inner(
46        dispatcher,
47        boot_guard,
48        true,
49        DaemonOptions::default(),
50        |_| DaemonStartupReport::default(),
51    )
52    .await
53}
54
55#[cfg(unix)]
56#[derive(Clone, Copy, PartialEq, Eq)]
57pub(super) enum RendezvousPathRole<'a> {
58    Socket,
59    PidFile,
60    Store(&'a std::path::Path),
61}
62
63#[cfg(unix)]
64impl RendezvousPathRole<'_> {
65    pub(super) fn refusal(
66        self,
67        rendezvous: std::fmt::Arguments<'_>,
68        store: std::fmt::Arguments<'_>,
69    ) -> anyhow::Error {
70        match self {
71            Self::Store(db_path) => anyhow::anyhow!(
72                "refusing to open events database {}: {store}. Check the offending directory's \
73                 ownership and permissions (remove group/other write access), or move the store \
74                 to a location trusted end to end. Only the first offending component is \
75                 reported; further untrusted ancestors or components may remain.",
76                db_path.display()
77            ),
78            Self::Socket | Self::PidFile => anyhow::anyhow!("{rendezvous}"),
79        }
80    }
81
82    pub(super) fn env_name(self) -> &'static str {
83        match self {
84            Self::Socket => SOCKET_PATH_ENV,
85            Self::PidFile => PID_PATH_ENV,
86            Self::Store(_) => "events database",
87        }
88    }
89
90    pub(super) fn directory_name(self) -> &'static str {
91        match self {
92            Self::Socket => "socket directory",
93            Self::PidFile => "PID-file directory",
94            Self::Store(_) => "store directory",
95        }
96    }
97
98    pub(super) fn path_name(self) -> &'static str {
99        match self {
100            Self::Socket => "socket path",
101            Self::PidFile => "PID-file path",
102            Self::Store(_) => "store path",
103        }
104    }
105
106    pub(super) fn path_component_name(self) -> &'static str {
107        match self {
108            Self::Socket => "socket-path",
109            Self::PidFile => "PID-file-path",
110            Self::Store(_) => "store-path",
111        }
112    }
113}
114
115/// Vet the socket's parent directory, re-permissioning it only when it is the
116/// directory khive owns by convention.
117///
118/// `KHIVE_SOCKET` takes an arbitrary path, and the previous unconditional
119/// chmod-0700 of its parent was wrong in both directions: pointed at a shared
120/// parent like `/tmp` it either failed outright for an ordinary user, or —
121/// worse — succeeded when privileged and stripped access for every other
122/// process on the machine. A directory we did not create is never modified.
123///
124/// What the directory must actually prevent is a *takeover of the socket
125/// path*: the connection gate is the 0600 socket plus the accept-time
126/// peer-uid check, but both defend the daemon's own socket — neither helps
127/// once another local user can put *their* listener at the path clients
128/// resolve. There are two ways a shared directory allows that, and the
129/// sticky bit closes neither: a writer can *pre-bind* the predictable path
130/// before this daemon starts (the sticky bit restricts unlinking, not
131/// creating), and the directory's *owner* can unlink and rebind even in a
132/// 1777 directory (sticky exempts the directory owner). So a caller-chosen
133/// directory is served only when it is trusted end to end: owned by this
134/// daemon's euid or root, and not writable by group or other at all. The
135/// umask-default 0755 stays acceptable; shared sticky directories like
136/// `/tmp` do not.
137#[cfg(unix)]
138pub(crate) fn ensure_socket_dir_is_trusted(parent: &std::path::Path) -> anyhow::Result<()> {
139    // SAFETY: `geteuid` is always successful and takes no arguments.
140    let daemon_euid = unsafe { libc::geteuid() } as u32;
141    ensure_rendezvous_dir_is_trusted(parent, RendezvousPathRole::Socket, daemon_euid, true)
142}
143
144/// Apply the existing directory trust and default-root repair rules to an events store.
145#[cfg(unix)]
146pub(crate) fn ensure_store_dir_is_trusted(
147    parent: &std::path::Path,
148    db_path: &std::path::Path,
149) -> anyhow::Result<()> {
150    // SAFETY: `geteuid` is always successful and takes no arguments.
151    let daemon_euid = unsafe { libc::geteuid() } as u32;
152    ensure_rendezvous_dir_is_trusted(
153        parent,
154        RendezvousPathRole::Store(db_path),
155        daemon_euid,
156        true,
157    )
158}
159
160/// Vet the parent directory of a PID file before reading, locking, or writing
161/// it. The file's lock only protects the inode currently named by its path;
162/// every directory component must therefore be as swap-resistant as the
163/// socket rendezvous.
164#[cfg(unix)]
165pub fn ensure_pid_file_dir_is_trusted(pid_file: &std::path::Path) -> anyhow::Result<()> {
166    let parent = pid_file
167        .parent()
168        .filter(|parent| !parent.as_os_str().is_empty())
169        .unwrap_or_else(|| std::path::Path::new("."));
170    // SAFETY: `geteuid` is always successful and takes no arguments.
171    let daemon_euid = unsafe { libc::geteuid() } as u32;
172    ensure_rendezvous_dir_is_trusted(parent, RendezvousPathRole::PidFile, daemon_euid, false)
173}
174
175#[cfg(unix)]
176pub(super) fn ensure_rendezvous_dir_is_trusted(
177    parent: &std::path::Path,
178    role: RendezvousPathRole<'_>,
179    daemon_euid: u32,
180    repair_owned_default: bool,
181) -> anyhow::Result<()> {
182    let env_name = role.env_name();
183    let directory_name = role.directory_name();
184
185    if repair_owned_default && parent == khive_dir() {
186        std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)).map_err(|e| {
187            role.refusal(
188                format_args!(
189                    "refusing to start: cannot chmod 0700 {}: {e}. The khive directory must be \
190                 owner-only as the {directory_name} for {env_name}; it is part of the \
191                 same-uid guarantee this daemon enforces.",
192                    parent.display()
193                ),
194                format_args!(
195                    "cannot chmod 0700 store directory {}: {e}",
196                    parent.display()
197                ),
198            )
199        })?;
200        return ensure_rendezvous_path_is_swap_resistant(parent, daemon_euid, role);
201    }
202
203    // Fail closed on the stat itself: not being able to read the metadata is
204    // not the same as the directory passing.
205    let meta = std::fs::metadata(parent).map_err(|e| {
206        role.refusal(
207            format_args!(
208                "refusing to start: cannot stat {directory_name} {} for {env_name}: {e}. \
209             It gates rendezvous-path safety, and unreadable metadata is not a passing state.",
210                parent.display()
211            ),
212            format_args!(
213                "cannot stat store directory {}: {e}; unreadable metadata is not a passing state",
214                parent.display()
215            ),
216        )
217    })?;
218
219    use std::os::unix::fs::MetadataExt;
220    let owner = meta.uid();
221    if owner != daemon_euid && owner != 0 {
222        return Err(role.refusal(
223            format_args!(
224                "refusing to start: {directory_name} {} for {env_name} is owned by uid {owner}, \
225             not this daemon's uid ({daemon_euid}) or root. A directory owner can replace \
226             the rendezvous path regardless of mode bits. Point {env_name} at a directory \
227             you own, or unset it for the default.",
228                parent.display()
229            ),
230            format_args!(
231                "store directory {} is owned by uid {owner}, not this process's uid \
232                 ({daemon_euid}) or root; its owner can replace the database path",
233                parent.display()
234            ),
235        ));
236    }
237
238    let mode = meta.permissions().mode();
239    if mode & 0o022 != 0 {
240        return Err(role.refusal(
241            format_args!(
242                "refusing to start: {directory_name} {} for {env_name} is mode {:04o} — writable \
243             by group or other, so another local user could replace the rendezvous path. \
244             Use a directory only you can write, or unset {env_name} for the default. \
245             This daemon is not changing the permissions of a directory it does not own.",
246                parent.display(),
247                mode & 0o7777
248            ),
249            format_args!(
250                "store directory {} is mode {:04o}, writable by group or other, so another local \
251                 user could replace the database path",
252                parent.display(),
253                mode & 0o7777
254            ),
255        ));
256    }
257
258    ensure_rendezvous_path_is_swap_resistant(parent, daemon_euid, role)
259}
260
261/// Socket-role form of [`ensure_rendezvous_path_is_swap_resistant`], used by the
262/// socket-path tests.
263#[cfg(all(unix, test))]
264pub(super) fn ensure_socket_path_is_swap_resistant(
265    parent: &std::path::Path,
266    daemon_euid: u32,
267) -> anyhow::Result<()> {
268    ensure_rendezvous_path_is_swap_resistant(parent, daemon_euid, RendezvousPathRole::Socket)
269}
270
271/// Walk the socket directory path exactly as the kernel will traverse it at
272/// bind time — component by component, following symlinks — and refuse any
273/// node another local user could swap after this validation.
274///
275/// Checking only the canonicalized result is not enough: bind and every
276/// client traverse the *original* path, so a symlink component owned by
277/// another user can resolve somewhere trusted while it is being validated
278/// and be retargeted before the socket is bound. Validating the nodes the
279/// traversal actually visits closes that gap: a symlink component is
280/// acceptable only when its owner — the only party besides root who can
281/// retarget it — is this daemon's euid or root, and every directory
282/// component is held to the ancestor rule below.
283///
284/// The directory rule is deliberately weaker than the immediate parent's.
285/// The parent hosts the socket file, where the threat is *creation* of the
286/// predictable path — the sticky bit does not restrict creating, so no
287/// sticky exception is sound there. An ancestor only threatens via
288/// *rename/unlink of an existing entry we own*, which the sticky bit does
289/// restrict: in a sticky directory, only the entry's owner, the directory's
290/// owner, or root may rename it. So a root-owned `/tmp` (1777) is an
291/// acceptable ancestor of a user-owned 0700 socket directory, while a
292/// non-sticky group/other-writable ancestor, or one owned by a third uid
293/// (who could rename the entry, or chmod the directory first), is refused.
294/// Every stat failure fails closed.
295#[cfg(unix)]
296pub(super) fn ensure_rendezvous_path_is_swap_resistant(
297    parent: &std::path::Path,
298    daemon_euid: u32,
299    role: RendezvousPathRole<'_>,
300) -> anyhow::Result<()> {
301    use std::os::unix::fs::MetadataExt;
302
303    let env_name = role.env_name();
304    let directory_name = role.directory_name();
305    let path_name = role.path_name();
306    let component_name = role.path_component_name();
307
308    let absolute = if parent.is_absolute() {
309        parent.to_path_buf()
310    } else {
311        std::env::current_dir()
312            .map_err(|e| {
313                role.refusal(
314                    format_args!(
315                        "refusing to start: cannot resolve the working directory to absolutize \
316                     {directory_name} {} for {env_name}: {e}.",
317                        parent.display()
318                    ),
319                    format_args!(
320                        "cannot resolve the working directory for store directory {}: {e}",
321                        parent.display()
322                    ),
323                )
324            })?
325            .join(parent)
326    };
327
328    fn push_components(stack: &mut Vec<std::ffi::OsString>, path: &std::path::Path) {
329        let components: Vec<_> = path
330            .components()
331            .map(|c| c.as_os_str().to_os_string())
332            .collect();
333        stack.extend(components.into_iter().rev());
334    }
335
336    let mut stack: Vec<std::ffi::OsString> = Vec::new();
337    push_components(&mut stack, &absolute);
338    let mut resolved = std::path::PathBuf::new();
339    let mut symlinks_followed = 0u32;
340
341    while let Some(component) = stack.pop() {
342        if component == "/" {
343            resolved = std::path::PathBuf::from("/");
344            continue;
345        }
346        if component == "." {
347            continue;
348        }
349        if component == ".." {
350            resolved.pop();
351            continue;
352        }
353        let candidate = resolved.join(&component);
354        let meta = std::fs::symlink_metadata(&candidate).map_err(|e| {
355            role.refusal(
356                format_args!(
357                    "refusing to start: cannot stat {component_name} component {} for {env_name}: \
358                 {e}. An unreadable component is not a passing one.",
359                    candidate.display()
360                ),
361                format_args!(
362                    "cannot stat store-path component {}: {e}; unreadable metadata is not a \
363                     passing state",
364                    candidate.display()
365                ),
366            )
367        })?;
368        let owner = meta.uid();
369
370        if meta.file_type().is_symlink() {
371            symlinks_followed += 1;
372            if symlinks_followed > 40 {
373                return Err(role.refusal(
374                    format_args!(
375                        "refusing to start: {path_name} for {env_name} resolves through more than \
376                     40 symlinks at {} — treating this as a loop.",
377                        candidate.display()
378                    ),
379                    format_args!(
380                        "store path resolves through more than 40 symlinks at {}; treating this as \
381                         a loop",
382                        candidate.display()
383                    ),
384                ));
385            }
386            if owner != daemon_euid && owner != 0 {
387                return Err(role.refusal(
388                    format_args!(
389                        "refusing to start: {component_name} symlink component {} for {env_name} \
390                     is owned by uid {owner}, not this daemon's uid ({daemon_euid}) or root — \
391                     its owner could retarget it after this check and re-root the {path_name}. \
392                     Point {env_name} somewhere trusted end to end, or unset it for the default.",
393                        candidate.display()
394                    ),
395                    format_args!(
396                        "store-path symlink component {} is owned by uid {owner}, not this \
397                         process's uid ({daemon_euid}) or root; its owner could retarget the \
398                         database path",
399                        candidate.display()
400                    ),
401                ));
402            }
403            let target = std::fs::read_link(&candidate).map_err(|e| {
404                role.refusal(
405                    format_args!(
406                        "refusing to start: cannot read {component_name} symlink component {} \
407                     for {env_name}: {e}.",
408                        candidate.display()
409                    ),
410                    format_args!(
411                        "cannot read store-path symlink component {}: {e}",
412                        candidate.display()
413                    ),
414                )
415            })?;
416            push_components(&mut stack, &target);
417            continue;
418        }
419
420        if meta.is_dir() {
421            let mode = meta.permissions().mode();
422            let sticky = mode & 0o1000 != 0;
423            if owner != daemon_euid && owner != 0 {
424                return Err(role.refusal(
425                    format_args!(
426                    "refusing to start: {component_name} ancestor {} for {env_name} is owned by \
427                     uid {owner}, not this daemon's uid ({daemon_euid}) or root — its owner \
428                     could rename the next path component and re-root the {path_name}. Point \
429                     {env_name} somewhere trusted end to end, or unset it for the default.",
430                    candidate.display()
431                    ),
432                    format_args!(
433                        "store-path ancestor {} is owned by uid {owner}, not this process's uid \
434                         ({daemon_euid}) or root; its owner could rename the next path component",
435                        candidate.display()
436                    ),
437                ));
438            }
439            if mode & 0o022 != 0 && !sticky {
440                return Err(role.refusal(
441                    format_args!(
442                        "refusing to start: {component_name} ancestor {} for {env_name} is mode \
443                     {:04o} — writable by group or other without the sticky bit, so another \
444                     local user could rename the next path component and re-root the {path_name}. \
445                     Point {env_name} somewhere trusted end to end, or unset it for the default.",
446                        candidate.display(),
447                        mode & 0o7777
448                    ),
449                    format_args!(
450                        "store-path ancestor {} is mode {:04o}, writable by group or other without \
451                         the sticky bit, so another local user could rename the next path \
452                         component",
453                        candidate.display(), mode & 0o7777
454                    ),
455                ));
456            }
457            resolved = candidate;
458            continue;
459        }
460
461        return Err(role.refusal(
462            format_args!(
463                "refusing to start: {component_name} component {} for {env_name} is neither a \
464             directory nor a symlink — the {path_name} cannot traverse it.",
465                candidate.display()
466            ),
467            format_args!(
468                "store-path component {} is neither a directory nor a symlink; the database path \
469                 cannot traverse it",
470                candidate.display()
471            ),
472        ));
473    }
474
475    Ok(())
476}