khive_runtime/daemon/locks.rs
1#[cfg(unix)]
2use std::os::unix::io::AsRawFd;
3
4#[cfg(unix)]
5use tokio::net::UnixStream;
6
7#[cfg(unix)]
8use super::{lock_path, recoverer_lock_path};
9
10#[cfg(unix)]
11pub(super) fn open_lock_file(path: &std::path::Path) -> std::io::Result<std::fs::File> {
12 if let Some(parent) = path.parent() {
13 let _ = std::fs::create_dir_all(parent);
14 }
15 std::fs::OpenOptions::new()
16 .create(true)
17 .truncate(false)
18 .write(true)
19 .open(path)
20}
21
22#[cfg(unix)]
23fn acquire_flock_blocking(path: &std::path::Path, label: &str) -> Option<std::fs::File> {
24 let file = match open_lock_file(path) {
25 Ok(f) => f,
26 Err(e) => {
27 tracing::warn!(target: "khive_runtime::daemon", error = %e, path = ?path, "cannot open {label} lock file");
28 return None;
29 }
30 };
31 // SAFETY: flock is a POSIX advisory lock with no memory side-effects.
32 let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) };
33 if rc != 0 {
34 tracing::warn!(target: "khive_runtime::daemon", "flock LOCK_EX failed on {label} lock");
35 return None;
36 }
37 Some(file)
38}
39
40/// Acquire an exclusive advisory flock on the recovery/startup lock file.
41///
42/// The returned `File` holds the lock for its lifetime; dropping it releases
43/// it. Used by both the client (serializing kill+spawn) and the daemon server
44/// (serializing cleanup+bind+pid-write) so the two critical sections are
45/// mutually exclusive across processes.
46#[cfg(unix)]
47pub fn acquire_recovery_lock() -> Option<std::fs::File> {
48 acquire_flock_blocking(&lock_path(), "recovery")
49}
50
51/// Attempt to acquire an exclusive advisory flock on `path`, retrying with a
52/// non-blocking `flock(LOCK_NB)` until `deadline` elapses. Bounded alternative
53/// to `acquire_recovery_lock`/`acquire_daemon_boot_guard`'s unbounded blocking
54/// flock — see `docs/api/daemon.md#try_acquire_flock_until` for why a caller
55/// merely detecting lock freedom needs a deadline instead.
56///
57/// - `Ok(Some(file))` — the lock was free within the deadline.
58/// - `Ok(None)` — `deadline` elapsed while the lock stayed held; an explicit
59/// "could not confirm" outcome, distinct from a hard I/O error.
60/// - `Err(_)` — the lock file could not be opened, or `flock` failed for a
61/// reason other than contention.
62///
63/// Blocking (paces retries with `std::thread::sleep`) — async callers must
64/// run this via `spawn_blocking`.
65#[cfg(unix)]
66fn try_acquire_flock_until(
67 path: &std::path::Path,
68 deadline: std::time::Instant,
69) -> std::io::Result<Option<std::fs::File>> {
70 let file = open_lock_file(path)?;
71 let poll_interval = std::time::Duration::from_millis(10);
72 loop {
73 // SAFETY: flock is a POSIX advisory lock with no memory side-effects.
74 let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
75 if rc == 0 {
76 return Ok(Some(file));
77 }
78 let err = std::io::Error::last_os_error();
79 if err.raw_os_error() != Some(libc::EWOULDBLOCK) {
80 return Err(err);
81 }
82 let now = std::time::Instant::now();
83 if now >= deadline {
84 return Ok(None);
85 }
86 std::thread::sleep(poll_interval.min(deadline - now));
87 }
88}
89
90/// Bounded, deadline-aware variant of [`acquire_daemon_boot_guard`]: attempts
91/// the SAME boot/recovery lock ([`lock_path`]) but gives up at `deadline`
92/// instead of blocking forever. For callers that need to detect "is a boot in
93/// progress right now" without risking an unbounded wait behind a wedged
94/// holder: e.g. khive-mcp's `confirm_genuinely_dead` re-probing rounds,
95/// where `DEAD_CONFIRM_ROUNDS` must bound elapsed time, not just probe count.
96#[cfg(unix)]
97pub fn try_acquire_daemon_boot_guard_until(
98 deadline: std::time::Instant,
99) -> std::io::Result<Option<DaemonBootGuard>> {
100 try_acquire_flock_until(&lock_path(), deadline)
101}
102
103/// Bounded, deadline-aware acquisition of the recoverer-only lock
104/// ([`recoverer_lock_path`]). See [`try_acquire_daemon_boot_guard_until`] for
105/// the shared rationale — a second recoverer waiting for a peer's dead
106/// confirmation/kill/spawn critical section must give up and report
107/// "uncertain" rather than block forever if that peer is itself wedged.
108#[cfg(unix)]
109pub fn try_acquire_recoverer_lock_until(
110 deadline: std::time::Instant,
111) -> std::io::Result<Option<std::fs::File>> {
112 try_acquire_flock_until(&recoverer_lock_path(), deadline)
113}
114
115/// Guard returned by [`acquire_daemon_boot_guard`], held across cold-boot
116/// schema initialization (migrations + pack schema plans / FTS DDL) through
117/// daemon bind + pid-write.
118#[cfg(unix)]
119pub type DaemonBootGuard = std::fs::File;
120
121/// Acquire the recovery/boot lock, treating failure as fatal.
122///
123/// Unlike [`acquire_recovery_lock`] (best-effort, `None` on failure: used by
124/// shutdown cleanup, where skipping unlink is safer than blocking forever),
125/// daemon-mode boot must hold this lock across migrations/FTS DDL through
126/// bind+pid-write. Silently continuing with no lock reopens the cold-boot FTS
127/// race this guard exists to close, so callers that are about to run
128/// daemon-mode boot (or wait for one to quiesce) must fail loudly instead of
129/// proceeding unguarded.
130#[cfg(unix)]
131pub fn acquire_daemon_boot_guard() -> anyhow::Result<DaemonBootGuard> {
132 acquire_recovery_lock()
133 .ok_or_else(|| anyhow::anyhow!("failed to acquire daemon boot/recovery lock"))
134}
135
136/// Identity of a bound Unix socket path, used to tell "the socket I bound" apart
137/// from "a same-path socket some other daemon bound after mine was removed".
138///
139/// A socket path can be recreated by a different process between the time
140/// this daemon captures its identity and the time it later checks it, so `dev`
141/// and `ino` (not the path) are what must match for cleanup to be safe.
142#[cfg(unix)]
143#[derive(Clone, Copy, PartialEq, Eq)]
144pub(super) struct SocketIdentity {
145 dev: u64,
146 ino: u64,
147}
148
149#[cfg(unix)]
150pub(super) fn socket_identity(path: &std::path::Path) -> Option<SocketIdentity> {
151 use std::os::unix::fs::MetadataExt;
152 let meta = std::fs::metadata(path).ok()?;
153 Some(SocketIdentity {
154 dev: meta.dev(),
155 ino: meta.ino(),
156 })
157}
158
159// ── connection principal ──────────────────────────────────────────────────────
160
161/// The uid on the other end of an accepted connection, read from the kernel.
162///
163/// This is the only identity on this socket the caller cannot choose. Every
164/// identity field on the request frame — `namespace`, `actor_id`,
165/// `visible_namespaces`, `config_id` — is supplied by the connecting process,
166/// so none of them can answer "who is this". A check reading self-asserted
167/// fields is not a weak gate, it is not a gate: anyone who wants to pass it
168/// asserts the passing values.
169///
170/// `getpeereid(2)` on macOS/BSD, `SO_PEERCRED` on Linux. Both report the peer's
171/// credentials as recorded by the kernel at connect time.
172#[cfg(unix)]
173pub(crate) fn peer_uid(stream: &UnixStream) -> std::io::Result<u32> {
174 use std::os::fd::AsRawFd;
175 let fd = stream.as_raw_fd();
176
177 #[cfg(any(target_os = "macos", target_os = "ios", target_vendor = "apple"))]
178 {
179 let mut uid: libc::uid_t = 0;
180 let mut gid: libc::gid_t = 0;
181 // SAFETY: `fd` is a live connected socket owned by `stream` for the
182 // duration of this call; both out-params are valid initialized locals.
183 let rc = unsafe { libc::getpeereid(fd, &mut uid, &mut gid) };
184 if rc != 0 {
185 return Err(std::io::Error::last_os_error());
186 }
187 Ok(uid as u32)
188 }
189
190 #[cfg(target_os = "linux")]
191 {
192 let mut cred = libc::ucred {
193 pid: 0,
194 uid: 0,
195 gid: 0,
196 };
197 let mut len = std::mem::size_of::<libc::ucred>() as libc::socklen_t;
198 // SAFETY: `fd` is a live connected socket owned by `stream`; `cred` is
199 // an initialized local of exactly `len` bytes, which is what
200 // SO_PEERCRED writes.
201 let rc = unsafe {
202 libc::getsockopt(
203 fd,
204 libc::SOL_SOCKET,
205 libc::SO_PEERCRED,
206 (&mut cred as *mut libc::ucred).cast::<libc::c_void>(),
207 &mut len,
208 )
209 };
210 if rc != 0 {
211 return Err(std::io::Error::last_os_error());
212 }
213 Ok(cred.uid)
214 }
215
216 #[cfg(not(any(
217 target_os = "linux",
218 target_os = "macos",
219 target_os = "ios",
220 target_vendor = "apple"
221 )))]
222 {
223 let _ = fd;
224 Err(std::io::Error::new(
225 std::io::ErrorKind::Unsupported,
226 "peer-credential capture is not implemented for this platform",
227 ))
228 }
229}
230
231/// Whether a connection from `uid` may be served by this daemon.
232///
233/// ADR-096 accepted per-request identity threading **for the single-principal
234/// owner-only socket only**, and rested that on three things: the `0600`
235/// socket, all connections being the same uid, and the database being already
236/// same-uid-accessible. The socket mode is asserted at bind. This asserts the
237/// second, which previously had no representation in the code at all — nothing
238/// read peer identity, so nothing could notice when it stopped being true.
239///
240/// **Principal is not attribution.** Many `actor_id`s over one socket is
241/// exactly what ADR-096 shipped and what every seat on a normal host does;
242/// refusing a second distinct actor would break the accepted design. The
243/// principal is the uid, and this refuses only a genuinely foreign one.
244///
245/// **There is deliberately no configuration escape hatch.** A flag permitting
246/// other uids would not weaken this assertion, it would delete it, in the way
247/// hardest to notice later: the check still exists, its tests still pass, and
248/// the deployment that matters has it off. A deployment that genuinely needs
249/// multiple uids needs a code change and a gated ADR — which is precisely the
250/// decision that should be impossible to make by accident.
251#[cfg(unix)]
252pub(crate) fn uid_is_permitted(peer: u32, daemon_euid: u32) -> bool {
253 peer == daemon_euid
254}