Skip to main content

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}