ados_protocol/ipc.rs
1//! Unix-socket IPC transport for the MAVLink and state sockets.
2//!
3//! Mirrors the semantics of `ADOSDroneAgent/src/ados/core/ipc.py`:
4//!
5//! - The owning service binds a Unix socket (perms `0o660`, group
6//! `ados-operator`, stale socket removed first) and broadcasts byte buffers to
7//! every connected client. The trusted local plane is root plus that group and
8//! nothing wider: two of these sockets forward inbound bytes straight to the
9//! flight controller. Plugins run as the `ados` user, which is never in the
10//! operator group, and every accept re-checks the peer's kernel credentials
11//! (see [`OperatorListener`]).
12//! - Each client has a bounded outbound queue. A client whose queue fills is
13//! dropped rather than allowed to grow unbounded (slow-client policy).
14//! - The state socket additionally replays the last buffer to a client the
15//! moment it connects, so a late subscriber does not wait for the next
16//! publish.
17//! - The MAVLink socket is bidirectional: a frame written by a client is a
18//! command toward the flight controller, surfaced here on an inbound channel.
19//!
20//! Framing is the caller's concern. [`broadcast`](IpcBroadcast::broadcast)
21//! sends the exact bytes given (the caller frames via [`crate::frame`] or
22//! [`crate::state`]). The inbound reader, used only by the MAVLink socket,
23//! decodes 4-byte big-endian length-prefixed frames.
24//!
25//! ## Who wrote this
26//!
27//! An inbound payload arrives as an [`InboundCommand`], which carries the
28//! writing connection's [`IpcPeer`] alongside the bytes. The owning service
29//! used to receive a bare buffer and had no way to tell one writer from
30//! another, so it recorded a single hardcoded provenance for every one of
31//! them. That reading was wrong for the writer that matters most: a process
32//! that accepts bytes from off the node and forwards them here is not the same
33//! as a process that produced them itself, and a service that cannot see the
34//! difference reports remote traffic as locally originated.
35
36use std::future::Future;
37use std::io;
38use std::os::unix::fs::PermissionsExt;
39use std::path::{Path, PathBuf};
40use std::sync::atomic::{AtomicU64, Ordering};
41use std::sync::Arc;
42use std::time::Duration;
43
44use bytes::Bytes;
45use tokio::io::{AsyncReadExt, AsyncWriteExt};
46use tokio::net::{UnixListener, UnixStream};
47use tokio::sync::{mpsc, Mutex};
48use tokio::task::JoinHandle;
49
50use crate::frame;
51
52/// The control payload a writer sends to declare that the bytes it is about to
53/// forward did not originate on this node.
54///
55/// Sent as an ordinary length-prefixed frame, so no framing changes and no
56/// existing writer is affected. It cannot collide with real traffic on either
57/// socket that carries inbound bytes: a MAVLink frame starts `0xFD` (v2) or
58/// `0xFE` (v1) and an MSP one starts `$`, while this starts with a NUL.
59///
60/// The declaration is one-way and downward. A writer can only ever give up the
61/// benefit of the doubt with it, never claim one, so a writer that does not
62/// send it is treated exactly as it was before and a writer that forges it only
63/// declassifies itself. It is consumed by the reader and never forwarded on.
64pub const IPC_DECLARE_OFF_BOX_SOURCE: &[u8] = b"\x00ados-ipc/source=off-box";
65
66mod injector;
67pub use injector::{
68 encode_injector_declaration, parse_injector_declaration, InjectorClaim,
69 IPC_DECLARE_INJECTOR_PREFIX,
70};
71
72/// Who wrote an inbound payload, as the accepting socket can tell.
73///
74/// The credentials come from the kernel (`SO_PEERCRED` and its equivalents),
75/// so they cannot be forged by the writer. `off_box_source` and `injector` are
76/// the writer's own declarations (see [`IPC_DECLARE_OFF_BOX_SOURCE`] /
77/// [`IPC_DECLARE_INJECTOR_PREFIX`]) and are sticky for the life of the
78/// connection, except that a plugin-plane writer ([`is_plugin_peer`]) starts
79/// out as an injector whatever it declares. Not `Copy`: the injector claim
80/// carries owned strings.
81#[derive(Debug, Clone, Default, PartialEq, Eq)]
82pub struct IpcPeer {
83 pub uid: Option<u32>,
84 pub gid: Option<u32>,
85 pub pid: Option<i32>,
86 /// The writing process has declared that it is forwarding bytes that
87 /// reached this node from somewhere else.
88 pub off_box_source: bool,
89 /// The writer is an autonomous injector, so its commands are subject to
90 /// the PIC arbiter: it declared itself one, or its kernel credentials put
91 /// it on the plugin plane. `None` = an operator/human writer, never gated.
92 pub injector: Option<InjectorClaim>,
93 /// This connection's serial, unique within the accepting process, so a
94 /// consumer can keep per-connection state (a partial frame) apart.
95 pub conn: u64,
96}
97
98/// One inbound payload plus the identity of the connection that wrote it.
99#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct InboundCommand {
101 pub payload: Vec<u8>,
102 pub peer: IpcPeer,
103}
104
105/// The shared service user plugin processes ran as before each plugin got its
106/// own user. Still treated as plugin plane wherever it exists.
107pub const LEGACY_PLUGIN_USER: &str = "ados";
108
109/// Whether a connecting peer is on the plugin plane: the shared plugin user,
110/// or any process carrying [`PLUGIN_GROUP`] as its primary or a supplementary
111/// group. Such a writer is autonomous by construction, so it is gated as an
112/// injector whether or not it declares itself one; a self-declaration only
113/// binds the cooperating ones.
114///
115/// Pure over the kernel-reported credentials so the decision is testable
116/// without a second uid.
117pub fn is_plugin_peer(
118 peer_uid: u32,
119 peer_gids: &[u32],
120 plugin_user_uid: Option<u32>,
121 plugin_gid: Option<u32>,
122) -> bool {
123 plugin_user_uid == Some(peer_uid) || plugin_gid.is_some_and(|gid| peer_gids.contains(&gid))
124}
125
126/// The injector claim a plugin-plane writer carries from the moment it
127/// connects. It has no ticket, so on a paired node it can never verify, and
128/// the PIC arbiter lets it through only on an affirmative "nobody holds
129/// control" report.
130fn plugin_plane_claim(uid: u32) -> InjectorClaim {
131 InjectorClaim {
132 client_id: format!("uid:{uid}"),
133 ticket: None,
134 }
135}
136
137/// Read the connecting process's credentials. A platform or kernel that will
138/// not answer leaves the fields empty rather than inventing a value.
139fn peer_identity(stream: &UnixStream) -> IpcPeer {
140 let Ok(cred) = stream.peer_cred() else {
141 return IpcPeer::default();
142 };
143 #[allow(unused_mut)]
144 let mut gids = vec![cred.gid()];
145 #[cfg(target_os = "linux")]
146 gids.extend(peer_supplementary_groups(stream));
147 let plugin = is_plugin_peer(
148 cred.uid(),
149 &gids,
150 user_uid(LEGACY_PLUGIN_USER),
151 group_gid(PLUGIN_GROUP),
152 );
153 IpcPeer {
154 uid: Some(cred.uid()),
155 gid: Some(cred.gid()),
156 pid: cred.pid(),
157 off_box_source: false,
158 injector: plugin.then(|| plugin_plane_claim(cred.uid())),
159 conn: 0,
160 }
161}
162
163fn user_uid(name: &str) -> Option<u32> {
164 match nix::unistd::User::from_name(name) {
165 Ok(user) => user.map(|u| u.uid.as_raw()),
166 Err(err) => {
167 tracing::debug!(error = %err, user = name, "resolving plugin user failed");
168 None
169 }
170 }
171}
172
173/// How long the accept loops back off after a failed `accept()` before
174/// retrying. Long enough that a persistent error cannot hot-spin the reactor,
175/// short enough that a transient one costs a client no perceptible delay.
176/// Shared by [`IpcBroadcast`] and [`serve_rpc`] so the two accept loops cannot
177/// drift into different recovery policies.
178const ACCEPT_RETRY_BACKOFF: Duration = Duration::from_millis(200);
179
180/// The next accepted connection's [`IpcPeer::conn`].
181static NEXT_CONN: AtomicU64 = AtomicU64::new(1);
182
183/// Kernel send buffer requested on every accepted broadcast client.
184///
185/// A stalled consumer is evicted once its kernel send buffer AND its
186/// outbound queue are full. Unpinned, the kernel buffer is
187/// `net.core.wmem_default`, which the installer raises to 4 MiB for video,
188/// so a stalled MAVLink, MSP or state reader buffered megabytes of stale
189/// frames before eviction. Linux doubles the request and caps it at
190/// `wmem_max`, so the effective buffer is at most 512 KiB.
191const IPC_CLIENT_SEND_BUFFER_BYTES: usize = 256 * 1024;
192
193/// Where an accept loop gets its client connections.
194///
195/// The real implementation is the bound [`UnixListener`]. It is a trait purely
196/// so the accept-ERROR branch is reachable from a test: a live `accept()` only
197/// fails under process-wide fd exhaustion or a kernel resource shortage, which
198/// cannot be induced in-process without wrecking every other test sharing the
199/// runtime. That is exactly why the branch which decides whether these sockets
200/// survive an accept error carried no coverage, and why it was a `break`.
201///
202/// Written as `-> impl Future<..> + Send` rather than `async fn` on purpose, and
203/// `clippy::manual_async_fn` is allowed for exactly that reason: the accept loop
204/// runs under `tokio::spawn`, so the returned future MUST be `Send`, and an
205/// `async fn` in a trait gives no way to say so. Simplifying to `async fn` makes
206/// the spawn fail to compile.
207trait ClientSource: Send + 'static {
208 #[allow(clippy::manual_async_fn)]
209 fn next_client(&mut self) -> impl Future<Output = io::Result<UnixStream>> + Send;
210}
211
212impl ClientSource for OperatorListener {
213 #[allow(clippy::manual_async_fn)]
214 fn next_client(&mut self) -> impl Future<Output = io::Result<UnixStream>> + Send {
215 async move {
216 OperatorListener::accept(self)
217 .await
218 .map(|(stream, _addr)| stream)
219 }
220 }
221}
222
223/// One connected client: its outbound queue plus the writer and reader tasks,
224/// so both can be aborted when the client is pruned or the server is dropped.
225///
226/// The queue carries [`Bytes`], not `Vec<u8>`: one broadcast buffer is handed to
227/// every attached client, so a `Vec` made that a full copy per client per frame.
228struct ClientHandle {
229 tx: mpsc::Sender<Bytes>,
230 writer: JoinHandle<()>,
231 reader: JoinHandle<()>,
232}
233
234/// A Unix-socket broadcast server. Drops slow clients; optionally replays the
235/// last buffer on connect; optionally decodes inbound length-prefixed frames.
236pub struct IpcBroadcast {
237 path: PathBuf,
238 queue_depth: usize,
239 clients: Arc<Mutex<Vec<ClientHandle>>>,
240 last: Arc<Mutex<Option<Bytes>>>,
241 keep_last: bool,
242 accept_task: JoinHandle<()>,
243 /// Monotonic count of clients evicted for falling behind — a full outbound
244 /// queue, and only that. A consumer that simply disconnected is pruned
245 /// without advancing this counter, so the number means "a consumer could
246 /// not keep up" rather than "a client went away". A slow consumer is
247 /// otherwise pruned silently; surfacing this counter lets the owning
248 /// service report the eviction the same way the radio TX-liveness watchdog
249 /// surfaces a stalled link — a silent drop is itself the failure mode to
250 /// detect, and a counter that also ticks on normal disconnects would drown
251 /// that signal.
252 dropped_clients: Arc<AtomicU64>,
253}
254
255impl IpcBroadcast {
256 /// Bind the socket and start accepting clients.
257 ///
258 /// - `queue_depth`: per-client outbound queue size (256 for MAVLink, 32 for
259 /// state, per the contracts).
260 /// - `keep_last`: replay the last broadcast buffer to a newly connected
261 /// client (state socket behaviour).
262 /// - `inbound`: if `Some`, each client connection is also read as a stream
263 /// of length-prefixed frames whose payloads are forwarded on the channel
264 /// as [`InboundCommand`]s carrying the writing connection's identity
265 /// (MAVLink command path). The capacity bounds the inbound backlog.
266 pub async fn bind(
267 path: impl AsRef<Path>,
268 queue_depth: usize,
269 keep_last: bool,
270 inbound: Option<usize>,
271 ) -> io::Result<(Self, Option<mpsc::Receiver<InboundCommand>>)> {
272 let path = path.as_ref().to_path_buf();
273 // Owner+group rw (0o660, group `ados-operator`), matching every other
274 // command socket in the tree; the shared helper owns the create-dir /
275 // remove-stale / bind / chmod / chgrp hygiene and the per-accept peer
276 // check.
277 //
278 // Two of these sockets — the MAVLink and MSP lanes — accept inbound
279 // bytes that are written straight through to the flight controller, so
280 // a peer that can open one can arm an aircraft. The trusted-local plane
281 // is root plus the operator group; plugins run as `ados`, outside it,
282 // and reach the flight controller through the plugin host, which gates
283 // every write on the plugin's granted capabilities.
284 let listener = bind_command_socket(&path, 0o660)?;
285
286 Ok(Self::from_source(
287 path,
288 listener,
289 queue_depth,
290 keep_last,
291 inbound,
292 ))
293 }
294
295 /// Start the server over an arbitrary [`ClientSource`].
296 ///
297 /// Split out of [`Self::bind`] so the accept loop's error-recovery branch is
298 /// exercisable: the source is the only part of the loop a test cannot drive
299 /// through a real socket.
300 fn from_source<S: ClientSource>(
301 path: PathBuf,
302 mut source: S,
303 queue_depth: usize,
304 keep_last: bool,
305 inbound: Option<usize>,
306 ) -> (Self, Option<mpsc::Receiver<InboundCommand>>) {
307 let clients: Arc<Mutex<Vec<ClientHandle>>> = Arc::new(Mutex::new(Vec::new()));
308 let last: Arc<Mutex<Option<Bytes>>> = Arc::new(Mutex::new(None));
309 let dropped_clients = Arc::new(AtomicU64::new(0));
310
311 let (inbound_tx, inbound_rx) = match inbound {
312 Some(cap) => {
313 let (tx, rx) = mpsc::channel::<InboundCommand>(cap);
314 (Some(tx), Some(rx))
315 }
316 None => (None, None),
317 };
318
319 let accept_clients = clients.clone();
320 let accept_last = last.clone();
321 let accept_task = tokio::spawn(async move {
322 loop {
323 let stream = match source.next_client().await {
324 Ok(s) => s,
325 Err(e) => {
326 // Never exit. This was `break`, which ended the accept
327 // task for the life of the process on ONE transient
328 // error — EMFILE/ENFILE under fd pressure, ENOBUFS,
329 // ECONNABORTED — while leaving the struct looking
330 // healthy: `client_count()` still reported the
331 // already-connected clients and `dropped_clients()`
332 // never ticked, so nothing anywhere could tell the
333 // socket had stopped accepting. Both the MAVLink and the
334 // state socket are bound through this one function, so a
335 // single hiccup silently closed the FC command surface
336 // and the telemetry snapshot to every future client
337 // until a manual restart.
338 //
339 // Back off so a persistent error cannot hot-spin, then
340 // retry forever — the same policy `serve_rpc` below
341 // already applies to the command sockets.
342 tracing::warn!(error = %e, "ipc broadcast socket accept failed");
343 tokio::time::sleep(ACCEPT_RETRY_BACKOFF).await;
344 continue;
345 }
346 };
347 Self::on_client(
348 stream,
349 queue_depth,
350 keep_last,
351 accept_clients.clone(),
352 accept_last.clone(),
353 inbound_tx.clone(),
354 )
355 .await;
356 }
357 });
358
359 (
360 Self {
361 path,
362 queue_depth,
363 clients,
364 last,
365 keep_last,
366 accept_task,
367 dropped_clients,
368 },
369 inbound_rx,
370 )
371 }
372
373 async fn on_client(
374 stream: UnixStream,
375 queue_depth: usize,
376 keep_last: bool,
377 clients: Arc<Mutex<Vec<ClientHandle>>>,
378 last: Arc<Mutex<Option<Bytes>>>,
379 inbound_tx: Option<mpsc::Sender<InboundCommand>>,
380 ) {
381 pin_send_buffer(&stream);
382 let mut peer = peer_identity(&stream);
383 peer.conn = NEXT_CONN.fetch_add(1, Ordering::Relaxed);
384 let (mut read_half, mut write_half) = stream.into_split();
385 let (tx, mut rx) = mpsc::channel::<Bytes>(queue_depth);
386
387 // Replay the last buffer immediately for the state socket.
388 if keep_last {
389 if let Some(buf) = last.lock().await.clone() {
390 let _ = tx.try_send(buf);
391 }
392 }
393
394 // Writer task: drain the queue to the socket, await flush so kernel
395 // backpressure stays inside the task and never blocks the producer.
396 let writer = tokio::spawn(async move {
397 while let Some(buf) = rx.recv().await {
398 if write_half.write_all(&buf).await.is_err() {
399 break;
400 }
401 if write_half.flush().await.is_err() {
402 break;
403 }
404 }
405 });
406
407 // Reader task. The MAVLink socket (inbound channel present) decodes
408 // length-prefixed frames and forwards the payloads as commands toward
409 // the flight controller. The write-only state socket has no inbound
410 // protocol, so it just drains raw bytes to detect EOF, matching the
411 // Python state server which only waits for the client to disconnect.
412 let reader = tokio::spawn(async move {
413 match inbound_tx {
414 Some(tx) => {
415 let mut peer = peer;
416 loop {
417 let mut header = [0u8; frame::HEADER_SIZE];
418 if read_half.read_exact(&mut header).await.is_err() {
419 break;
420 }
421 let len = match frame::decode_len(header, frame::MAVLINK_MAX_FRAME, false) {
422 Ok(n) => n,
423 Err(_) => break,
424 };
425 let mut payload = vec![0u8; len];
426 if len > 0 && read_half.read_exact(&mut payload).await.is_err() {
427 break;
428 }
429 // The origin declaration is a control frame, not
430 // traffic: it latches for the rest of the connection
431 // and is not passed on to the flight controller.
432 if payload == IPC_DECLARE_OFF_BOX_SOURCE {
433 peer.off_box_source = true;
434 continue;
435 }
436 if let Some(claim) = parse_injector_declaration(&payload) {
437 // Sticky, one-way: a writer subjects itself to the PIC
438 // arbiter. Never forwarded to the FC.
439 peer.injector = Some(claim);
440 continue;
441 }
442 if tx
443 .send(InboundCommand {
444 payload,
445 peer: peer.clone(),
446 })
447 .await
448 .is_err()
449 {
450 break;
451 }
452 }
453 }
454 None => {
455 let mut scratch = [0u8; 256];
456 while read_half.read(&mut scratch).await.unwrap_or(0) > 0 {}
457 }
458 }
459 });
460
461 clients
462 .lock()
463 .await
464 .push(ClientHandle { tx, writer, reader });
465 }
466
467 /// Broadcast a byte buffer to all connected clients. Clients whose queue is
468 /// full are dropped. If `keep_last` is set, the buffer is also stored as the
469 /// last-state replayed to future clients.
470 ///
471 /// Takes [`Bytes`] rather than `Vec<u8>` because every clone below — one per
472 /// attached client, plus one for the retained last-state — used to be a full
473 /// copy of the buffer. On the FC frame lane that is ~66 copies/s per
474 /// connected consumer, growing with the number of consumers; as `Bytes` each
475 /// is a refcount bump and the cost stops scaling with the client count. A
476 /// producer holding a freshly built `Vec<u8>` converts with `.into()`, which
477 /// is a move, not a copy.
478 pub async fn broadcast(&self, buf: Bytes) {
479 if self.keep_last {
480 *self.last.lock().await = Some(buf.clone());
481 }
482 let mut clients = self.clients.lock().await;
483 let mut keep = Vec::with_capacity(clients.len());
484 let mut dropped = 0u64;
485 for client in clients.drain(..) {
486 match client.tx.try_send(buf.clone()) {
487 Ok(()) => keep.push(client),
488 // Queue full: the consumer is genuinely not draining. Evict it
489 // and count it — a slow consumer is a real fault, and this
490 // counter is the only producer-visible signal that it happened.
491 Err(mpsc::error::TrySendError::Full(_)) => {
492 client.reader.abort();
493 client.writer.abort();
494 dropped += 1;
495 }
496 // Channel closed: the consumer already went away. That is a
497 // normal disconnect, NOT a slow client, so it is pruned without
498 // touching the eviction counter. Counting it reported every
499 // short-lived reader as a fault: `ados-cloud` opens a fresh
500 // connection per enrichment tick (~1 Hz), reads the replayed
501 // last-state and closes, which alone logged ~86k spurious
502 // `ipc_slow_client_evicted` warnings a day and buried the real
503 // evictions in the noise.
504 Err(mpsc::error::TrySendError::Closed(_)) => {
505 client.reader.abort();
506 client.writer.abort();
507 }
508 }
509 }
510 *clients = keep;
511 // One relaxed add for the whole broadcast rather than per pruned
512 // client; the owning service polls this and reports it.
513 if dropped > 0 {
514 self.dropped_clients.fetch_add(dropped, Ordering::Relaxed);
515 }
516 }
517
518 /// Number of currently connected clients.
519 pub async fn client_count(&self) -> usize {
520 self.clients.lock().await.len()
521 }
522
523 /// The per-client queue depth this server was bound with.
524 pub fn queue_depth(&self) -> usize {
525 self.queue_depth
526 }
527
528 /// Monotonic count of clients evicted for falling behind — a full outbound
529 /// queue — since this server was bound. A client that disconnected cleanly
530 /// is not counted here.
531 ///
532 /// A slow consumer is pruned without an error reaching it, so this counter
533 /// is the producer-visible signal that an eviction happened. The owning
534 /// service folds it into the state snapshot it already publishes so the
535 /// eviction is observable remotely rather than silent. Lock-free.
536 pub fn dropped_clients(&self) -> u64 {
537 self.dropped_clients.load(Ordering::Relaxed)
538 }
539}
540
541impl Drop for IpcBroadcast {
542 fn drop(&mut self) {
543 self.accept_task.abort();
544 // Abort the per-client reader/writer tasks so none survive the server.
545 if let Ok(clients) = self.clients.try_lock() {
546 for client in clients.iter() {
547 client.reader.abort();
548 client.writer.abort();
549 }
550 }
551 let _ = std::fs::remove_file(&self.path);
552 }
553}
554
555/// The group that owns every agent command-plane socket: the control socket,
556/// the `*-cmd.sock` command sockets, the MAVLink/MSP/state lanes, and the rest
557/// of the sockets bound through [`bind_command_socket`]. The installer creates
558/// it and adds the human operator; the plugin user is never a member, whatever
559/// capabilities it holds (a sensor-bus reader joins [`SENSORS_GROUP`]).
560pub const OPERATOR_GROUP: &str = "ados-operator";
561
562/// The group that may write `/run/ados/capture` (the capture-active marker
563/// directory, `2775 root:ados-capture`) and the capture root, which the
564/// supervisor's janitor and the installer keep at `3775 root:ados-capture`.
565/// A capture plugin joins it only while it holds `capture.storage.write`; it
566/// guards nothing else.
567pub const CAPTURE_GROUP: &str = "ados-capture";
568
569/// The reader group of the sensor bus (`/run/ados/sensing/sensors.sock`), bound with
570/// [`bind_reader_socket`]. A member may connect and subscribe; what it
571/// publishes is dropped. A plugin joins it only while it holds
572/// `sensor.stream.read`, which is how a sandboxed plugin reads the bus
573/// without joining [`OPERATOR_GROUP`]. The installer also adds the human
574/// operator, whose operator membership still decides what it may publish.
575pub const SENSORS_GROUP: &str = "ados-sensors";
576
577/// The group every plugin unit runs in as its primary group. Each plugin has
578/// its own system user; this shared group owns only the per-plugin sockets
579/// ([`bind_plugin_socket`]), which gate every request on the plugin's own
580/// capability token, and it is what the plugin loopback guard and the router
581/// match a plugin process by.
582pub const PLUGIN_GROUP: &str = "ados-plugins";
583
584/// Whether a connecting peer may use a command-plane socket.
585///
586/// Root always may. So may a peer running as the same uid as the serving
587/// process: every agent service runs as root, so on a node this adds nothing,
588/// and on a dev host it lets the owning user's own tools in. Anyone else must
589/// carry the operator group, primary or supplementary. With the group absent
590/// from the host (`operator_gid` is `None`) nobody else is admitted.
591///
592/// Pure over the kernel-reported credentials so the decision is testable
593/// without a second uid.
594pub fn operator_peer_allowed(
595 peer_uid: u32,
596 peer_gids: &[u32],
597 server_uid: u32,
598 operator_gid: Option<u32>,
599) -> bool {
600 peer_uid == 0
601 || peer_uid == server_uid
602 || operator_gid.is_some_and(|gid| peer_gids.contains(&gid))
603}
604
605/// A bound command-plane socket. [`accept`](Self::accept) yields only peers
606/// [`operator_peer_allowed`] admits (plus, on a socket bound with
607/// [`bind_reader_socket`], members of its reader group); any other peer is
608/// closed before a byte of it is read.
609///
610/// The socket file is already `0o660 root:ados-operator` (or `root:<reader
611/// group>`), so the kernel refuses `connect()` to anyone outside the group. The
612/// per-accept check is the second line: it holds when the chgrp could not be
613/// applied, when the mode is widened by hand, or when a descriptor to the
614/// socket is passed to a process that could not have opened it.
615pub struct OperatorListener {
616 inner: UnixListener,
617 path: PathBuf,
618 server_uid: u32,
619 operator_gid: Option<u32>,
620 /// The reader group's gid on a [`bind_reader_socket`] socket.
621 reader_gid: Option<u32>,
622}
623
624impl OperatorListener {
625 /// Accept the next admitted peer. Refused peers are logged and dropped; the
626 /// call keeps waiting for one that is admitted. An `accept()` error is
627 /// returned to the caller's own retry policy unchanged.
628 pub async fn accept(&self) -> io::Result<(UnixStream, tokio::net::unix::SocketAddr)> {
629 loop {
630 let (stream, addr) = self.inner.accept().await?;
631 if self.classify(&stream).is_some() {
632 return Ok((stream, addr));
633 }
634 }
635 }
636
637 /// [`accept`](Self::accept), also returning what the admitted peer is
638 /// ([`classify_peer`]), so a server can narrow what each class may do.
639 pub async fn accept_classified(&self) -> io::Result<(UnixStream, PeerClass)> {
640 loop {
641 let (stream, _) = self.inner.accept().await?;
642 if let Some(class) = self.classify(&stream) {
643 return Ok((stream, class));
644 }
645 }
646 }
647
648 fn classify(&self, stream: &UnixStream) -> Option<PeerClass> {
649 match peer_credentials(stream) {
650 Ok((uid, gids)) => {
651 let class = classify_peer(
652 uid,
653 &gids,
654 self.server_uid,
655 self.operator_gid,
656 self.reader_gid,
657 );
658 if class.is_none() {
659 tracing::warn!(
660 path = %self.path.display(),
661 peer_uid = uid,
662 "command socket refused a peer outside the operator group"
663 );
664 }
665 class
666 }
667 // The narrowest class the socket admits.
668 Err(err) if peer_hung_up_before_accept(&err) => Some(if self.reader_gid.is_some() {
669 PeerClass::Reader
670 } else {
671 PeerClass::Operator
672 }),
673 Err(err) => {
674 tracing::warn!(
675 path = %self.path.display(),
676 error = %err,
677 "command socket refused a peer with unreadable credentials"
678 );
679 None
680 }
681 }
682 }
683}
684
685/// The connecting peer's uid and group set, as the kernel reports them. An
686/// error means the platform would not answer, which the caller treats as a
687/// refusal (see `peer_hung_up_before_accept` for the one exception).
688fn peer_credentials(stream: &UnixStream) -> io::Result<(u32, Vec<u32>)> {
689 let cred = stream.peer_cred()?;
690 #[allow(unused_mut)]
691 let mut gids = vec![cred.gid()];
692 #[cfg(target_os = "linux")]
693 gids.extend(peer_supplementary_groups(stream));
694 Ok((cred.uid(), gids))
695}
696
697/// Whether the process on the other end of `stream` is on the operator plane:
698/// root, this process's own uid, or a member of [`OPERATOR_GROUP`] (the same
699/// policy [`OperatorListener`] enforces). For a socket that must also accept
700/// plugin-plane peers (the log ingest sink), this tells the two apart so what a
701/// plugin writes is recorded as the plugin's. Unreadable credentials read as
702/// not-operator, which can only narrow trust, except for the development-host
703/// case `peer_hung_up_before_accept` documents.
704pub fn is_operator_peer(stream: &UnixStream) -> bool {
705 match peer_credentials(stream) {
706 Ok((uid, gids)) => operator_peer_allowed(
707 uid,
708 &gids,
709 nix::unistd::geteuid().as_raw(),
710 group_gid(OPERATOR_GROUP),
711 ),
712 Err(err) => peer_hung_up_before_accept(&err),
713 }
714}
715
716/// Linux records `SO_PEERCRED` at `connect()` and answers it even after the
717/// peer has closed. macOS answers `LOCAL_PEERCRED` with `ENOTCONN` once the peer
718/// has hung up, even while its bytes are still queued, so a client that sends
719/// and closes before the accept would lose everything it sent. macOS is a
720/// development host only, and the socket file mode still gates `connect()`
721/// there, so a peer that already hung up is admitted on non-Linux hosts.
722#[cfg(not(target_os = "linux"))]
723fn peer_hung_up_before_accept(err: &io::Error) -> bool {
724 err.kind() == io::ErrorKind::NotConnected
725}
726
727#[cfg(target_os = "linux")]
728fn peer_hung_up_before_accept(_err: &io::Error) -> bool {
729 false
730}
731
732/// The peer's supplementary groups at `connect()` time (`SO_PEERGROUPS`). An
733/// error reads as no supplementary groups, which can only narrow admission.
734#[cfg(target_os = "linux")]
735fn peer_supplementary_groups(stream: &UnixStream) -> Vec<u32> {
736 use nix::libc;
737 use std::os::fd::AsRawFd;
738
739 const GID_SIZE: usize = std::mem::size_of::<libc::gid_t>();
740 let fd = stream.as_raw_fd();
741 let mut groups: Vec<libc::gid_t> = vec![0; 32];
742 // One retry: on ERANGE the kernel writes the size it needs into `len`.
743 for _ in 0..2 {
744 let mut len = (groups.len() * GID_SIZE) as libc::socklen_t;
745 // SAFETY: `groups` is a live, writable buffer of exactly `len` bytes,
746 // and the kernel writes at most `len` bytes into it and updates `len`.
747 let rc = unsafe {
748 libc::getsockopt(
749 fd,
750 libc::SOL_SOCKET,
751 libc::SO_PEERGROUPS,
752 groups.as_mut_ptr().cast(),
753 &mut len,
754 )
755 };
756 if rc == 0 {
757 groups.truncate(len as usize / GID_SIZE);
758 return groups;
759 }
760 let needed = len as usize / GID_SIZE;
761 if io::Error::last_os_error().raw_os_error() != Some(libc::ERANGE) || needed <= groups.len()
762 {
763 break;
764 }
765 groups.resize(needed, 0);
766 }
767 Vec::new()
768}
769
770/// Pin the kernel send buffer of an accepted broadcast client to
771/// [`IPC_CLIENT_SEND_BUFFER_BYTES`], so how much a stalled reader can hold
772/// before eviction does not follow the system-wide `wmem_default`. A failure
773/// leaves the system default in place; the client is still served.
774fn pin_send_buffer(stream: &UnixStream) {
775 use nix::libc;
776 use std::os::fd::AsRawFd;
777
778 let size = IPC_CLIENT_SEND_BUFFER_BYTES as libc::c_int;
779 // SAFETY: the pointer refers to a live `c_int` and the length passed is
780 // exactly its size; the kernel only reads from it.
781 let rc = unsafe {
782 libc::setsockopt(
783 stream.as_raw_fd(),
784 libc::SOL_SOCKET,
785 libc::SO_SNDBUF,
786 std::ptr::from_ref(&size).cast(),
787 std::mem::size_of::<libc::c_int>() as libc::socklen_t,
788 )
789 };
790 if rc != 0 {
791 tracing::warn!(
792 error = %io::Error::last_os_error(),
793 requested = IPC_CLIENT_SEND_BUFFER_BYTES,
794 "ipc_client_sndbuf_set_failed"
795 );
796 }
797}
798
799/// Bind a command-plane socket: create the parent directory, remove a stale
800/// socket left by a prior run (so `bind` cannot fail with `EADDRINUSE`), bind,
801/// set the file mode, and hand the file to [`OPERATOR_GROUP`]. Every accept on
802/// the returned listener re-checks the peer (see [`OperatorListener`]).
803///
804/// [`IpcBroadcast::bind`] and the one-shot command sockets share it so the
805/// hygiene and the peer policy live in one place.
806///
807/// Like [`UnixListener::bind`], this must be called from within a Tokio runtime
808/// context.
809pub fn bind_command_socket(path: impl AsRef<Path>, mode: u32) -> io::Result<OperatorListener> {
810 let path = path.as_ref();
811 let inner = bind_socket_file(path, mode)?;
812 let operator_gid = group_gid(OPERATOR_GROUP);
813 set_socket_group(path, OPERATOR_GROUP, operator_gid);
814 Ok(OperatorListener {
815 inner,
816 path: path.to_path_buf(),
817 server_uid: nix::unistd::geteuid().as_raw(),
818 operator_gid,
819 reader_gid: None,
820 })
821}
822
823mod peer_class;
824pub use peer_class::{bind_reader_socket, classify_peer, PeerClass};
825
826/// Bind a socket on the plugin-reachable plane: the same hygiene as
827/// [`bind_command_socket`], owned by [`PLUGIN_GROUP`] so a plugin's unit can
828/// connect. There is no peer check here, so it is only for surfaces a plugin
829/// may legitimately reach: the per-plugin sockets, where the server gates every
830/// request on the plugin's own capability token, and the log ingest sink, which
831/// only accepts log frames. Never use it for an agent command socket.
832pub fn bind_plugin_socket(path: impl AsRef<Path>, mode: u32) -> io::Result<UnixListener> {
833 let path = path.as_ref();
834 let listener = bind_socket_file(path, mode)?;
835 set_socket_group(path, PLUGIN_GROUP, group_gid(PLUGIN_GROUP));
836 Ok(listener)
837}
838
839fn bind_socket_file(path: &Path, mode: u32) -> io::Result<UnixListener> {
840 if let Some(parent) = path.parent() {
841 std::fs::create_dir_all(parent).ok();
842 }
843 // Remove a stale socket so bind does not fail with EADDRINUSE.
844 let _ = std::fs::remove_file(path);
845 let listener = UnixListener::bind(path)?;
846 std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))?;
847 Ok(listener)
848}
849
850fn group_gid(name: &str) -> Option<u32> {
851 match nix::unistd::Group::from_name(name) {
852 Ok(group) => group.map(|g| g.gid.as_raw()),
853 Err(err) => {
854 tracing::debug!(error = %err, group = name, "resolving socket group failed");
855 None
856 }
857 }
858}
859
860/// Give a freshly-bound socket to `group`.
861///
862/// This belongs next to the mode, not in each caller, because the two are one
863/// decision: every service here runs as root, so a `0o660` socket grants
864/// *nobody but root* until the group owns the file.
865///
866/// Best-effort by design: the installer creates the groups, and on a dev host
867/// where one is absent this is a quiet no-op, which leaves the socket reachable
868/// by its owner only. Linux-only; a stub elsewhere.
869#[cfg(target_os = "linux")]
870fn set_socket_group(path: &Path, group: &str, gid: Option<u32>) {
871 let Some(gid) = gid else {
872 tracing::debug!(
873 group,
874 "socket group not present; leaving socket group as-is"
875 );
876 return;
877 };
878 if let Err(err) = nix::unistd::chown(path, None, Some(nix::unistd::Gid::from_raw(gid))) {
879 tracing::debug!(error = %err, path = %path.display(), group, "chgrp socket failed");
880 }
881}
882
883#[cfg(not(target_os = "linux"))]
884fn set_socket_group(_path: &Path, _group: &str, _gid: Option<u32>) {}
885
886/// Serve a one-shot request/response command socket.
887///
888/// The accept loop never exits on a transient `accept()` error: it logs, backs
889/// off briefly, and retries, so a momentary fd-pressure hiccup cannot silently
890/// kill the command surface for the process lifetime (a command socket that
891/// dies while the service stays up would need a manual restart). Each
892/// connection is handled on its own task: read exactly one newline-terminated
893/// request (capped at
894/// `max_request` bytes so a peer that never sends a newline cannot grow the
895/// buffer without bound; an over-cap request closes the connection with no
896/// response), call `handler` with the request bytes (trailing newline
897/// stripped), write the returned bytes followed by a single trailing newline,
898/// flush, and close.
899///
900/// `handler` carries the pure parse + dispatch: it takes the raw request line
901/// and returns the raw response line. Framing (the trailing newline) and the
902/// socket lifecycle are owned here, so a service only supplies its dispatch.
903///
904/// Use this only for sockets that are unambiguously one-shot newline
905/// request/response. A length-prefixed, streaming, stateful (handshake), or
906/// multi-request socket keeps its own accept loop.
907pub async fn serve_rpc<H, Fut>(listener: OperatorListener, max_request: usize, handler: H)
908where
909 H: Fn(Vec<u8>) -> Fut + Clone + Send + Sync + 'static,
910 Fut: Future<Output = Vec<u8>> + Send + 'static,
911{
912 loop {
913 let mut stream = match listener.accept().await {
914 Ok((s, _addr)) => s,
915 Err(e) => {
916 // Backoff so a persistent accept error cannot hot-spin; never
917 // exit, so the command surface survives transient fd pressure.
918 tracing::warn!(error = %e, "command socket accept failed");
919 tokio::time::sleep(ACCEPT_RETRY_BACKOFF).await;
920 continue;
921 }
922 };
923 let handler = handler.clone();
924 tokio::spawn(async move {
925 // One newline-terminated request per connection. A clean EOF before
926 // any request, or a line that exceeds the cap, just closes.
927 let request = match read_newline_line(&mut stream, max_request).await {
928 Ok(Some(req)) => req,
929 Ok(None) | Err(_) => return,
930 };
931 let mut response = handler(request).await;
932 response.push(b'\n');
933 if stream.write_all(&response).await.is_err() {
934 return;
935 }
936 let _ = stream.flush().await;
937 // `stream` drops here, closing the connection.
938 });
939 }
940}
941
942/// Connect to a Unix socket with bounded retry (mirrors the Python client's
943/// connect-with-retry). Returns the stream once connected.
944pub async fn connect_with_retry(
945 path: impl AsRef<Path>,
946 retries: u32,
947 delay: Duration,
948) -> io::Result<UnixStream> {
949 let path = path.as_ref();
950 let mut last_err: Option<io::Error> = None;
951 for attempt in 0..retries.max(1) {
952 match UnixStream::connect(path).await {
953 Ok(s) => return Ok(s),
954 Err(e) => {
955 last_err = Some(e);
956 if attempt + 1 < retries {
957 tokio::time::sleep(delay).await;
958 }
959 }
960 }
961 }
962 Err(last_err.unwrap_or_else(|| io::Error::new(io::ErrorKind::NotFound, "connect failed")))
963}
964
965/// Read one length-prefixed frame from a stream. Returns `Ok(None)` on clean
966/// EOF. Used to consume the MAVLink broadcast on the client side.
967pub async fn read_length_prefixed<R: AsyncReadExt + Unpin>(
968 reader: &mut R,
969 max: usize,
970 reject_zero: bool,
971) -> io::Result<Option<Vec<u8>>> {
972 let mut header = [0u8; frame::HEADER_SIZE];
973 match reader.read_exact(&mut header).await {
974 Ok(_) => {}
975 Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => return Ok(None),
976 Err(e) => return Err(e),
977 }
978 let len = frame::decode_len(header, max, reject_zero)
979 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?;
980 let mut payload = vec![0u8; len];
981 if len > 0 {
982 reader.read_exact(&mut payload).await?;
983 }
984 Ok(Some(payload))
985}
986
987/// Read one newline-terminated line (state socket v1) from a stream. Returns
988/// `Ok(None)` on clean EOF. The returned buffer excludes the trailing newline.
989///
990/// `max` caps the line length so a peer that never sends a newline cannot grow
991/// the buffer without bound. A line that reaches `max` without a newline
992/// returns `InvalidData`.
993pub async fn read_newline_line<R: AsyncReadExt + Unpin>(
994 reader: &mut R,
995 max: usize,
996) -> io::Result<Option<Vec<u8>>> {
997 let mut buf = Vec::new();
998 let mut byte = [0u8; 1];
999 loop {
1000 match reader.read_exact(&mut byte).await {
1001 Ok(_) => {
1002 if byte[0] == b'\n' {
1003 return Ok(Some(buf));
1004 }
1005 if buf.len() >= max {
1006 return Err(io::Error::new(
1007 io::ErrorKind::InvalidData,
1008 "state line exceeded maximum length without a newline",
1009 ));
1010 }
1011 buf.push(byte[0]);
1012 }
1013 Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => {
1014 return if buf.is_empty() {
1015 Ok(None)
1016 } else {
1017 Ok(Some(buf))
1018 };
1019 }
1020 Err(e) => return Err(e),
1021 }
1022 }
1023}
1024
1025#[cfg(test)]
1026mod tests {
1027 use super::*;
1028 use crate::frame::{encode_frame, MAVLINK_MAX_FRAME};
1029
1030 fn temp_sock(name: &str) -> PathBuf {
1031 let mut p = std::env::temp_dir();
1032 let unique = format!("ados-ipc-test-{}-{}.sock", std::process::id(), name);
1033 p.push(unique);
1034 let _ = std::fs::remove_file(&p);
1035 p
1036 }
1037
1038 /// A client source that fails a fixed number of times before delegating to
1039 /// a real listener.
1040 ///
1041 /// EMFILE is chosen because it is the error that actually happens: the agent
1042 /// runs a dozen services against one fd table, and a burst of plugin or
1043 /// proxy connections is the realistic way `accept()` fails on a flight
1044 /// daemon. It cannot be induced in-process without lowering the whole
1045 /// process's fd limit, which is why this seam exists.
1046 struct FlakyAcceptor {
1047 listener: OperatorListener,
1048 failures_left: usize,
1049 }
1050
1051 impl ClientSource for FlakyAcceptor {
1052 // Same reason as the trait: the accept loop is spawned, so the future
1053 // must be `Send`, which `async fn` in a trait cannot state.
1054 #[allow(clippy::manual_async_fn)]
1055 fn next_client(&mut self) -> impl Future<Output = io::Result<UnixStream>> + Send {
1056 async move {
1057 if self.failures_left > 0 {
1058 self.failures_left -= 1;
1059 // 24 == EMFILE on both Linux and macOS.
1060 return Err(io::Error::from_raw_os_error(24));
1061 }
1062 self.listener.accept().await.map(|(s, _addr)| s)
1063 }
1064 }
1065 }
1066
1067 /// One transient `accept()` error must not close the socket for the life of
1068 /// the process.
1069 ///
1070 /// Why the `Err(_) => break` this replaces survived review: nothing observable
1071 /// changes when the accept task exits. The socket is still bound and the
1072 /// kernel still accepts into its backlog, so a client's `connect()` SUCCEEDS
1073 /// and the client then hangs forever with no frames. Meanwhile
1074 /// `client_count()` keeps reporting the clients that connected before the
1075 /// error and `dropped_clients()` never ticks, so no counter, log line or
1076 /// health probe anywhere can tell that the socket stopped accepting. Both
1077 /// `mavlink.sock` and `state.sock` are bound through this one function, so a
1078 /// single hiccup under fd pressure silently closed the FC command surface and
1079 /// the telemetry snapshot to every future client until a manual restart.
1080 ///
1081 /// The assertion is therefore on DELIVERY to a client accepted after the
1082 /// errors, not on the loop's internal state — which is the only thing that
1083 /// distinguishes the two behaviours from outside.
1084 #[tokio::test]
1085 async fn the_accept_loop_survives_an_accept_error_and_still_serves_the_next_client() {
1086 let path = temp_sock("acceptretry");
1087 let listener = bind_command_socket(&path, 0o660).unwrap();
1088 let (server, _inbound) = IpcBroadcast::from_source(
1089 path.clone(),
1090 FlakyAcceptor {
1091 listener,
1092 failures_left: 2,
1093 },
1094 256,
1095 false,
1096 None,
1097 );
1098
1099 // A client that connects only AFTER the loop has already absorbed two
1100 // accept failures. Before the fix the loop had exited on the first one,
1101 // so this connect succeeded at the kernel (the socket is still bound and
1102 // its backlog still accepts) and then hung forever with no frames — the
1103 // failure mode that made this invisible.
1104 let mut client = connect_with_retry(&path, 200, Duration::from_millis(20))
1105 .await
1106 .unwrap();
1107 // Two backoffs plus registration.
1108 tokio::time::sleep(ACCEPT_RETRY_BACKOFF * 3).await;
1109 assert_eq!(server.client_count().await, 1);
1110
1111 server
1112 .broadcast(
1113 encode_frame(b"post-emfile", MAVLINK_MAX_FRAME)
1114 .unwrap()
1115 .into(),
1116 )
1117 .await;
1118 let got = tokio::time::timeout(
1119 Duration::from_secs(2),
1120 read_length_prefixed(&mut client, MAVLINK_MAX_FRAME, false),
1121 )
1122 .await
1123 .expect("a frame must reach a client accepted after an accept error")
1124 .unwrap();
1125 assert_eq!(got.as_deref(), Some(&b"post-emfile"[..]));
1126 }
1127
1128 #[tokio::test]
1129 async fn broadcasts_framed_payloads_to_all_clients() {
1130 let path = temp_sock("bcast");
1131 let (server, _inbound) = IpcBroadcast::bind(&path, 256, false, None).await.unwrap();
1132
1133 let mut c1 = connect_with_retry(&path, 10, Duration::from_millis(20))
1134 .await
1135 .unwrap();
1136 let mut c2 = connect_with_retry(&path, 10, Duration::from_millis(20))
1137 .await
1138 .unwrap();
1139 // Let the accept loop register both clients.
1140 tokio::time::sleep(Duration::from_millis(50)).await;
1141 assert_eq!(server.client_count().await, 2);
1142
1143 server
1144 .broadcast(encode_frame(b"hello", MAVLINK_MAX_FRAME).unwrap().into())
1145 .await;
1146
1147 let f1 = read_length_prefixed(&mut c1, MAVLINK_MAX_FRAME, false)
1148 .await
1149 .unwrap();
1150 let f2 = read_length_prefixed(&mut c2, MAVLINK_MAX_FRAME, false)
1151 .await
1152 .unwrap();
1153 assert_eq!(f1.as_deref(), Some(&b"hello"[..]));
1154 assert_eq!(f2.as_deref(), Some(&b"hello"[..]));
1155 }
1156
1157 #[tokio::test]
1158 async fn replays_last_state_on_connect() {
1159 let path = temp_sock("laststate");
1160 let (server, _inbound) = IpcBroadcast::bind(&path, 32, true, None).await.unwrap();
1161
1162 // Publish before any client connects.
1163 server
1164 .broadcast(Bytes::from_static(b"{\"armed\":false}\n"))
1165 .await;
1166
1167 let mut client = connect_with_retry(&path, 10, Duration::from_millis(20))
1168 .await
1169 .unwrap();
1170 let line = read_newline_line(&mut client, crate::state::STATE_V2_MAX_FRAME)
1171 .await
1172 .unwrap();
1173 assert_eq!(line.as_deref(), Some(&b"{\"armed\":false}"[..]));
1174 }
1175
1176 #[tokio::test]
1177 async fn slow_client_is_dropped_fast_client_survives() {
1178 let path = temp_sock("slow");
1179 // Small queue so a non-draining client fills it once the kernel socket
1180 // buffer is also full.
1181 let (server, _inbound) = IpcBroadcast::bind(&path, 2, false, None).await.unwrap();
1182
1183 // "slow" connects but never reads; "fast" connects and drains continuously.
1184 let _slow = connect_with_retry(&path, 10, Duration::from_millis(20))
1185 .await
1186 .unwrap();
1187 let mut fast = connect_with_retry(&path, 10, Duration::from_millis(20))
1188 .await
1189 .unwrap();
1190 let fast_reader = tokio::spawn(async move {
1191 while let Ok(Some(_)) = read_length_prefixed(&mut fast, MAVLINK_MAX_FRAME, false).await
1192 {
1193 }
1194 });
1195 tokio::time::sleep(Duration::from_millis(50)).await;
1196 assert_eq!(server.client_count().await, 2);
1197
1198 // Large payloads fill the slow client's kernel send buffer; its writer
1199 // then blocks, its queue fills to depth 2, and the next broadcast drops
1200 // it. The fast client keeps draining so it never fills.
1201 let big = vec![0xABu8; 60_000];
1202 for _ in 0..60u32 {
1203 server
1204 .broadcast(encode_frame(&big, MAVLINK_MAX_FRAME).unwrap().into())
1205 .await;
1206 tokio::time::sleep(Duration::from_millis(2)).await;
1207 }
1208 tokio::time::sleep(Duration::from_millis(50)).await;
1209
1210 // The slow client has been pruned; the fast client remains.
1211 assert_eq!(server.client_count().await, 1);
1212 // The eviction is observable: the drop counter advanced by exactly the
1213 // one stalled consumer, not the fast one that kept draining.
1214 assert_eq!(server.dropped_clients(), 1);
1215 fast_reader.abort();
1216 }
1217
1218 #[tokio::test]
1219 async fn dropped_client_counter_advances_when_a_consumer_stalls() {
1220 let path = temp_sock("dropcount");
1221 // Depth-2 queue so a non-draining client is evicted quickly once its
1222 // kernel send buffer and then its queue fill.
1223 let (server, _inbound) = IpcBroadcast::bind(&path, 2, false, None).await.unwrap();
1224
1225 // A counter starts at zero before any client exists.
1226 assert_eq!(server.dropped_clients(), 0);
1227
1228 // One consumer connects and then never reads a single byte.
1229 let _stalled = connect_with_retry(&path, 10, Duration::from_millis(20))
1230 .await
1231 .unwrap();
1232 tokio::time::sleep(Duration::from_millis(50)).await;
1233 assert_eq!(server.client_count().await, 1);
1234
1235 // Push large payloads until the stalled consumer's kernel buffer and
1236 // queue fill and the next broadcast evicts it.
1237 let big = vec![0x5Au8; 60_000];
1238 for _ in 0..60u32 {
1239 server
1240 .broadcast(encode_frame(&big, MAVLINK_MAX_FRAME).unwrap().into())
1241 .await;
1242 tokio::time::sleep(Duration::from_millis(2)).await;
1243 if server.client_count().await == 0 {
1244 break;
1245 }
1246 }
1247 tokio::time::sleep(Duration::from_millis(50)).await;
1248
1249 // The stalled consumer was pruned, and the eviction is no longer
1250 // silent: the producer-visible counter advanced.
1251 assert_eq!(server.client_count().await, 0);
1252 assert_eq!(server.dropped_clients(), 1);
1253
1254 // The counter is monotonic: it does not reset across further broadcasts
1255 // once the client is gone (no remaining client to drop).
1256 server
1257 .broadcast(encode_frame(b"tail", MAVLINK_MAX_FRAME).unwrap().into())
1258 .await;
1259 assert_eq!(server.dropped_clients(), 1);
1260 }
1261
1262 #[tokio::test]
1263 async fn a_clean_disconnect_is_not_counted_as_a_slow_client() {
1264 // The state-socket shape: replay the last buffer on connect, which is
1265 // what a short-lived reader comes for.
1266 let path = temp_sock("cleandisconnect");
1267 let (server, _inbound) = IpcBroadcast::bind(&path, 32, true, None).await.unwrap();
1268 server
1269 .broadcast(encode_frame(b"snapshot", MAVLINK_MAX_FRAME).unwrap().into())
1270 .await;
1271
1272 // This is exactly the `ados-cloud` enrichment pattern: connect, take the
1273 // replayed snapshot, disconnect. Repeated at ~1 Hz it used to log an
1274 // `ipc_slow_client_evicted` every second on a perfectly healthy agent.
1275 for _ in 0..3u32 {
1276 let client = connect_with_retry(&path, 10, Duration::from_millis(20))
1277 .await
1278 .unwrap();
1279 tokio::time::sleep(Duration::from_millis(30)).await;
1280 drop(client);
1281 tokio::time::sleep(Duration::from_millis(30)).await;
1282
1283 // Two broadcasts: the first may still find the queue writable
1284 // before the writer task notices the peer is gone, the second is
1285 // guaranteed to meet a closed channel.
1286 for _ in 0..2u32 {
1287 server
1288 .broadcast(encode_frame(b"tick", MAVLINK_MAX_FRAME).unwrap().into())
1289 .await;
1290 tokio::time::sleep(Duration::from_millis(20)).await;
1291 }
1292 }
1293
1294 // The departed clients are pruned...
1295 assert_eq!(server.client_count().await, 0);
1296 // ...but none of them was slow, so the eviction counter must be
1297 // untouched. Before the Full/Closed split this read 3.
1298 assert_eq!(server.dropped_clients(), 0);
1299 }
1300
1301 #[tokio::test]
1302 async fn inbound_frames_from_client_reach_the_channel() {
1303 let path = temp_sock("inbound");
1304 let (_server, inbound) = IpcBroadcast::bind(&path, 256, false, Some(16))
1305 .await
1306 .unwrap();
1307 let mut inbound = inbound.expect("inbound channel requested");
1308
1309 let mut client = connect_with_retry(&path, 10, Duration::from_millis(20))
1310 .await
1311 .unwrap();
1312 // Client writes a framed command toward the FC.
1313 let framed = encode_frame(b"command", MAVLINK_MAX_FRAME).unwrap();
1314 client.write_all(&framed).await.unwrap();
1315 client.flush().await.unwrap();
1316
1317 let got = tokio::time::timeout(Duration::from_millis(500), inbound.recv())
1318 .await
1319 .unwrap()
1320 .expect("a command");
1321 assert_eq!(got.payload, b"command");
1322 // The kernel answers for a local peer, so the writing process is
1323 // identified rather than assumed.
1324 assert!(
1325 got.peer.uid.is_some(),
1326 "the accepting socket must record who wrote the payload"
1327 );
1328 assert!(!got.peer.off_box_source);
1329 }
1330
1331 #[tokio::test]
1332 async fn a_declared_off_box_source_sticks_and_is_not_forwarded() {
1333 let path = temp_sock("offbox");
1334 let (_server, inbound) = IpcBroadcast::bind(&path, 256, false, Some(16))
1335 .await
1336 .unwrap();
1337 let mut inbound = inbound.expect("inbound channel requested");
1338
1339 let mut client = connect_with_retry(&path, 10, Duration::from_millis(20))
1340 .await
1341 .unwrap();
1342 for payload in [IPC_DECLARE_OFF_BOX_SOURCE, b"first", b"second"] {
1343 let framed = encode_frame(payload, MAVLINK_MAX_FRAME).unwrap();
1344 client.write_all(&framed).await.unwrap();
1345 }
1346 client.flush().await.unwrap();
1347
1348 // The declaration itself never reaches the consumer, and both payloads
1349 // behind it are marked for the whole life of the connection.
1350 for expected in [&b"first"[..], &b"second"[..]] {
1351 let got = tokio::time::timeout(Duration::from_millis(500), inbound.recv())
1352 .await
1353 .unwrap()
1354 .expect("a command");
1355 assert_eq!(got.payload, expected);
1356 assert!(
1357 got.peer.off_box_source,
1358 "a declared forwarder must not read as a local producer"
1359 );
1360 }
1361 }
1362
1363 #[test]
1364 fn encode_injector_declaration_round_trips_through_the_parser() {
1365 // The producer's encoder and the router's parser must agree byte-for-byte,
1366 // with and without a ticket — a drift here silently un-arms the gate.
1367 for (id, ticket) in [("ai-mission", Some("abc.def")), ("autopilot-1", None)] {
1368 let frame = encode_injector_declaration(id, ticket);
1369 assert!(frame.starts_with(IPC_DECLARE_INJECTOR_PREFIX));
1370 assert_eq!(
1371 parse_injector_declaration(&frame),
1372 Some(InjectorClaim {
1373 client_id: id.into(),
1374 ticket: ticket.map(str::to_string),
1375 })
1376 );
1377 }
1378 }
1379
1380 #[test]
1381 fn parse_injector_declaration_reads_id_and_optional_ticket() {
1382 // id only
1383 let mut d = IPC_DECLARE_INJECTOR_PREFIX.to_vec();
1384 d.extend_from_slice(b"ai-mission");
1385 assert_eq!(
1386 parse_injector_declaration(&d),
1387 Some(InjectorClaim {
1388 client_id: "ai-mission".into(),
1389 ticket: None
1390 })
1391 );
1392 // id + ticket
1393 let mut d2 = IPC_DECLARE_INJECTOR_PREFIX.to_vec();
1394 d2.extend_from_slice(b"ai-mission;ticket=abc.def");
1395 assert_eq!(
1396 parse_injector_declaration(&d2),
1397 Some(InjectorClaim {
1398 client_id: "ai-mission".into(),
1399 ticket: Some("abc.def".into())
1400 })
1401 );
1402 // an empty client id is not a claim
1403 let mut empty = IPC_DECLARE_INJECTOR_PREFIX.to_vec();
1404 empty.extend_from_slice(b";ticket=abc");
1405 assert_eq!(parse_injector_declaration(&empty), None);
1406 // an unrelated payload is not an injector declaration
1407 assert_eq!(parse_injector_declaration(b"command"), None);
1408 assert_eq!(parse_injector_declaration(IPC_DECLARE_OFF_BOX_SOURCE), None);
1409 }
1410
1411 #[tokio::test]
1412 async fn a_declared_injector_sticks_and_is_not_forwarded() {
1413 let path = temp_sock("injector");
1414 let (_server, inbound) = IpcBroadcast::bind(&path, 256, false, Some(16))
1415 .await
1416 .unwrap();
1417 let mut inbound = inbound.expect("inbound channel requested");
1418
1419 let mut client = connect_with_retry(&path, 10, Duration::from_millis(20))
1420 .await
1421 .unwrap();
1422 let mut decl = IPC_DECLARE_INJECTOR_PREFIX.to_vec();
1423 decl.extend_from_slice(b"ai-mission;ticket=tkt");
1424 for payload in [decl.as_slice(), b"first", b"second"] {
1425 let framed = encode_frame(payload, MAVLINK_MAX_FRAME).unwrap();
1426 client.write_all(&framed).await.unwrap();
1427 }
1428 client.flush().await.unwrap();
1429
1430 // The declaration never reaches the FC, and both payloads behind it carry
1431 // the sticky injector claim.
1432 for expected in [&b"first"[..], &b"second"[..]] {
1433 let got = tokio::time::timeout(Duration::from_millis(500), inbound.recv())
1434 .await
1435 .unwrap()
1436 .expect("a command");
1437 assert_eq!(got.payload, expected);
1438 let claim = got.peer.injector.expect("a declared injector claim");
1439 assert_eq!(claim.client_id, "ai-mission");
1440 assert_eq!(claim.ticket.as_deref(), Some("tkt"));
1441 }
1442 }
1443
1444 #[tokio::test]
1445 async fn one_connection_declaring_does_not_mark_another() {
1446 let path = temp_sock("offbox-scope");
1447 let (_server, inbound) = IpcBroadcast::bind(&path, 256, false, Some(16))
1448 .await
1449 .unwrap();
1450 let mut inbound = inbound.expect("inbound channel requested");
1451
1452 let mut declaring = connect_with_retry(&path, 10, Duration::from_millis(20))
1453 .await
1454 .unwrap();
1455 declaring
1456 .write_all(&encode_frame(IPC_DECLARE_OFF_BOX_SOURCE, MAVLINK_MAX_FRAME).unwrap())
1457 .await
1458 .unwrap();
1459 declaring.flush().await.unwrap();
1460
1461 let mut plain = connect_with_retry(&path, 10, Duration::from_millis(20))
1462 .await
1463 .unwrap();
1464 plain
1465 .write_all(&encode_frame(b"local", MAVLINK_MAX_FRAME).unwrap())
1466 .await
1467 .unwrap();
1468 plain.flush().await.unwrap();
1469
1470 let got = tokio::time::timeout(Duration::from_millis(500), inbound.recv())
1471 .await
1472 .unwrap()
1473 .expect("a command");
1474 assert_eq!(got.payload, b"local");
1475 assert!(!got.peer.off_box_source);
1476 }
1477
1478 #[tokio::test]
1479 async fn read_newline_line_caps_an_endless_line() {
1480 // A peer that never sends a newline must not grow the buffer past the
1481 // cap: the reader returns InvalidData instead of allocating unbounded.
1482 let (mut a, mut b) = UnixStream::pair().unwrap();
1483 let writer = tokio::spawn(async move {
1484 // Send more bytes than the cap, with no newline.
1485 let chunk = vec![b'x'; 64];
1486 for _ in 0..10 {
1487 if a.write_all(&chunk).await.is_err() {
1488 break;
1489 }
1490 let _ = a.flush().await;
1491 }
1492 // Keep the stream open so the reader hits the cap, not EOF.
1493 tokio::time::sleep(Duration::from_secs(2)).await;
1494 });
1495
1496 let err = read_newline_line(&mut b, 128).await.unwrap_err();
1497 assert_eq!(err.kind(), io::ErrorKind::InvalidData);
1498 writer.abort();
1499 }
1500
1501 #[tokio::test]
1502 async fn bind_command_socket_removes_stale_and_sets_mode() {
1503 let path = temp_sock("bindhelper");
1504 // Leave a stale regular file where the socket will go; bind must remove
1505 // it rather than fail with EADDRINUSE.
1506 std::fs::write(&path, b"stale").unwrap();
1507 assert!(path.exists());
1508
1509 let listener = bind_command_socket(&path, 0o660).unwrap();
1510
1511 // The requested permission bits are set on the bound socket.
1512 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
1513 assert_eq!(mode & 0o777, 0o660);
1514
1515 drop(listener);
1516 let _ = std::fs::remove_file(&path);
1517 }
1518
1519 #[tokio::test]
1520 async fn a_broadcast_socket_is_never_world_accessible() {
1521 // Regression guard. These sockets were bound 0o666 to match an older
1522 // Python server, and two of them — the MAVLink and MSP lanes — forward
1523 // inbound bytes straight to the flight controller, so world-write meant
1524 // any local process could arm an aircraft. The trusted local plane is
1525 // root plus the operator group and nothing wider.
1526 let path = temp_sock("bcast-perm");
1527 let (server, _inbound) = IpcBroadcast::bind(&path, 8, false, None).await.unwrap();
1528
1529 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
1530 assert_eq!(
1531 mode & 0o007,
1532 0,
1533 "broadcast socket must carry no world bits, got {:o}",
1534 mode & 0o777
1535 );
1536 assert_eq!(mode & 0o777, 0o660, "expected owner+group rw only");
1537
1538 drop(server);
1539 let _ = std::fs::remove_file(&path);
1540 }
1541
1542 #[tokio::test]
1543 async fn serve_rpc_round_trips_a_request_and_closes_over_cap() {
1544 let path = temp_sock("rpc");
1545 let listener = bind_command_socket(&path, 0o660).unwrap();
1546 // Echo handler: prefix the request so the test sees the request bytes
1547 // (trailing newline stripped) reached the dispatch closure.
1548 let server = tokio::spawn(serve_rpc(listener, 16, |req: Vec<u8>| async move {
1549 let mut resp = b"echo:".to_vec();
1550 resp.extend_from_slice(&req);
1551 resp
1552 }));
1553
1554 // A well-formed one-shot request gets one newline-terminated response.
1555 let mut client = connect_with_retry(&path, 10, Duration::from_millis(20))
1556 .await
1557 .unwrap();
1558 client.write_all(b"ping\n").await.unwrap();
1559 client.flush().await.unwrap();
1560 let line = read_newline_line(&mut client, 256).await.unwrap();
1561 assert_eq!(line.as_deref(), Some(&b"echo:ping"[..]));
1562 drop(client);
1563
1564 // A request longer than the cap (16) with no newline closes the
1565 // connection with no response. The client then reads no reply: on
1566 // some platforms that is a clean EOF (`Ok(None)`), on others the
1567 // server closing with unread data resets the connection (`Err`).
1568 // Both mean "closed without a reply".
1569 let mut over = connect_with_retry(&path, 10, Duration::from_millis(20))
1570 .await
1571 .unwrap();
1572 let _ = over.write_all(&[b'x'; 64]).await;
1573 let _ = over.flush().await;
1574 let outcome = read_newline_line(&mut over, 256).await;
1575 assert!(
1576 matches!(outcome, Ok(None) | Err(_)),
1577 "over-cap request must get no reply, got {outcome:?}"
1578 );
1579
1580 server.abort();
1581 let _ = std::fs::remove_file(&path);
1582 }
1583
1584 #[test]
1585 fn plugin_plane_peers_are_injectors_whatever_they_declare() {
1586 let plugin_gid = Some(980);
1587 let legacy_user = Some(995);
1588 // A per-plugin user: primary group is the plugin group.
1589 assert!(is_plugin_peer(1201, &[980], legacy_user, plugin_gid));
1590 // The plugin group as a supplementary group counts too.
1591 assert!(is_plugin_peer(1201, &[1201, 980], legacy_user, plugin_gid));
1592 // The legacy shared plugin user, whatever its groups.
1593 assert!(is_plugin_peer(995, &[995], legacy_user, plugin_gid));
1594 // Root and an operator are not.
1595 assert!(!is_plugin_peer(0, &[0], legacy_user, plugin_gid));
1596 assert!(!is_plugin_peer(1000, &[1000, 990], legacy_user, plugin_gid));
1597 // Neither the user nor the group exists on this host: nobody matches.
1598 assert!(!is_plugin_peer(1201, &[980], None, None));
1599 // The claim a plugin peer starts with can never verify on a paired node.
1600 assert_eq!(plugin_plane_claim(1201).ticket, None);
1601 }
1602
1603 #[test]
1604 fn operator_peer_policy_admits_root_the_server_uid_and_operator_members_only() {
1605 let operator_gid = Some(990);
1606 // A plugin process: uid 1000, primary group 1000, no operator group.
1607 assert!(!operator_peer_allowed(1000, &[1000], 0, operator_gid));
1608 // The same uid with the operator group as a supplementary group.
1609 assert!(operator_peer_allowed(1000, &[1000, 990], 0, operator_gid));
1610 // The operator group as the primary group counts too.
1611 assert!(operator_peer_allowed(1000, &[990], 0, operator_gid));
1612 // Root and the serving process's own uid are always admitted.
1613 assert!(operator_peer_allowed(0, &[0], 0, operator_gid));
1614 assert!(operator_peer_allowed(501, &[20], 501, operator_gid));
1615 // With the operator group absent from the host nobody else gets in,
1616 // whatever groups it carries.
1617 assert!(!operator_peer_allowed(1000, &[1000, 990], 0, None));
1618 }
1619
1620 #[tokio::test]
1621 async fn a_refused_peer_is_closed_before_its_request_is_read() {
1622 // Root is admitted by rule, so this can only be observed as non-root.
1623 if nix::unistd::geteuid().is_root() {
1624 return;
1625 }
1626 let path = temp_sock("refused");
1627 let mut listener = bind_command_socket(&path, 0o660).unwrap();
1628 // Serve as a different uid with no operator group on the host: the
1629 // test's own uid is then an outsider, exactly like a plugin process.
1630 listener.server_uid = u32::MAX - 1;
1631 listener.operator_gid = None;
1632 let reached = Arc::new(AtomicU64::new(0));
1633 let counter = reached.clone();
1634 let server = tokio::spawn(serve_rpc(listener, 64, move |_req: Vec<u8>| {
1635 let counter = counter.clone();
1636 async move {
1637 counter.fetch_add(1, Ordering::Relaxed);
1638 b"served".to_vec()
1639 }
1640 }));
1641
1642 let mut client = connect_with_retry(&path, 10, Duration::from_millis(20))
1643 .await
1644 .unwrap();
1645 let _ = client.write_all(b"ping\n").await;
1646 let _ = client.flush().await;
1647 let outcome =
1648 tokio::time::timeout(Duration::from_secs(2), read_newline_line(&mut client, 256))
1649 .await
1650 .expect("a refused peer must be closed, not left hanging");
1651 assert!(
1652 matches!(outcome, Ok(None) | Err(_)),
1653 "a refused peer must get no reply, got {outcome:?}"
1654 );
1655 assert_eq!(
1656 reached.load(Ordering::Relaxed),
1657 0,
1658 "the handler must never run"
1659 );
1660
1661 server.abort();
1662 let _ = std::fs::remove_file(&path);
1663 }
1664}