use std::future::Future;
use std::io;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
use bytes::Bytes;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{UnixListener, UnixStream};
use tokio::sync::{mpsc, Mutex};
use tokio::task::JoinHandle;
use crate::frame;
pub const IPC_DECLARE_OFF_BOX_SOURCE: &[u8] = b"\x00ados-ipc/source=off-box";
mod injector;
pub use injector::{
encode_injector_declaration, parse_injector_declaration, InjectorClaim,
IPC_DECLARE_INJECTOR_PREFIX,
};
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct IpcPeer {
pub uid: Option<u32>,
pub gid: Option<u32>,
pub pid: Option<i32>,
pub off_box_source: bool,
pub injector: Option<InjectorClaim>,
pub conn: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InboundCommand {
pub payload: Vec<u8>,
pub peer: IpcPeer,
}
pub const LEGACY_PLUGIN_USER: &str = "ados";
pub fn is_plugin_peer(
peer_uid: u32,
peer_gids: &[u32],
plugin_user_uid: Option<u32>,
plugin_gid: Option<u32>,
) -> bool {
plugin_user_uid == Some(peer_uid) || plugin_gid.is_some_and(|gid| peer_gids.contains(&gid))
}
fn plugin_plane_claim(uid: u32) -> InjectorClaim {
InjectorClaim {
client_id: format!("uid:{uid}"),
ticket: None,
}
}
fn peer_identity(stream: &UnixStream) -> IpcPeer {
let Ok(cred) = stream.peer_cred() else {
return IpcPeer::default();
};
#[allow(unused_mut)]
let mut gids = vec![cred.gid()];
#[cfg(target_os = "linux")]
gids.extend(peer_supplementary_groups(stream));
let plugin = is_plugin_peer(
cred.uid(),
&gids,
user_uid(LEGACY_PLUGIN_USER),
group_gid(PLUGIN_GROUP),
);
IpcPeer {
uid: Some(cred.uid()),
gid: Some(cred.gid()),
pid: cred.pid(),
off_box_source: false,
injector: plugin.then(|| plugin_plane_claim(cred.uid())),
conn: 0,
}
}
fn user_uid(name: &str) -> Option<u32> {
match nix::unistd::User::from_name(name) {
Ok(user) => user.map(|u| u.uid.as_raw()),
Err(err) => {
tracing::debug!(error = %err, user = name, "resolving plugin user failed");
None
}
}
}
const ACCEPT_RETRY_BACKOFF: Duration = Duration::from_millis(200);
static NEXT_CONN: AtomicU64 = AtomicU64::new(1);
const IPC_CLIENT_SEND_BUFFER_BYTES: usize = 256 * 1024;
trait ClientSource: Send + 'static {
#[allow(clippy::manual_async_fn)]
fn next_client(&mut self) -> impl Future<Output = io::Result<UnixStream>> + Send;
}
impl ClientSource for OperatorListener {
#[allow(clippy::manual_async_fn)]
fn next_client(&mut self) -> impl Future<Output = io::Result<UnixStream>> + Send {
async move {
OperatorListener::accept(self)
.await
.map(|(stream, _addr)| stream)
}
}
}
struct ClientHandle {
tx: mpsc::Sender<Bytes>,
writer: JoinHandle<()>,
reader: JoinHandle<()>,
}
pub struct IpcBroadcast {
path: PathBuf,
queue_depth: usize,
clients: Arc<Mutex<Vec<ClientHandle>>>,
last: Arc<Mutex<Option<Bytes>>>,
keep_last: bool,
accept_task: JoinHandle<()>,
dropped_clients: Arc<AtomicU64>,
}
impl IpcBroadcast {
pub async fn bind(
path: impl AsRef<Path>,
queue_depth: usize,
keep_last: bool,
inbound: Option<usize>,
) -> io::Result<(Self, Option<mpsc::Receiver<InboundCommand>>)> {
let path = path.as_ref().to_path_buf();
let listener = bind_command_socket(&path, 0o660)?;
Ok(Self::from_source(
path,
listener,
queue_depth,
keep_last,
inbound,
))
}
fn from_source<S: ClientSource>(
path: PathBuf,
mut source: S,
queue_depth: usize,
keep_last: bool,
inbound: Option<usize>,
) -> (Self, Option<mpsc::Receiver<InboundCommand>>) {
let clients: Arc<Mutex<Vec<ClientHandle>>> = Arc::new(Mutex::new(Vec::new()));
let last: Arc<Mutex<Option<Bytes>>> = Arc::new(Mutex::new(None));
let dropped_clients = Arc::new(AtomicU64::new(0));
let (inbound_tx, inbound_rx) = match inbound {
Some(cap) => {
let (tx, rx) = mpsc::channel::<InboundCommand>(cap);
(Some(tx), Some(rx))
}
None => (None, None),
};
let accept_clients = clients.clone();
let accept_last = last.clone();
let accept_task = tokio::spawn(async move {
loop {
let stream = match source.next_client().await {
Ok(s) => s,
Err(e) => {
tracing::warn!(error = %e, "ipc broadcast socket accept failed");
tokio::time::sleep(ACCEPT_RETRY_BACKOFF).await;
continue;
}
};
Self::on_client(
stream,
queue_depth,
keep_last,
accept_clients.clone(),
accept_last.clone(),
inbound_tx.clone(),
)
.await;
}
});
(
Self {
path,
queue_depth,
clients,
last,
keep_last,
accept_task,
dropped_clients,
},
inbound_rx,
)
}
async fn on_client(
stream: UnixStream,
queue_depth: usize,
keep_last: bool,
clients: Arc<Mutex<Vec<ClientHandle>>>,
last: Arc<Mutex<Option<Bytes>>>,
inbound_tx: Option<mpsc::Sender<InboundCommand>>,
) {
pin_send_buffer(&stream);
let mut peer = peer_identity(&stream);
peer.conn = NEXT_CONN.fetch_add(1, Ordering::Relaxed);
let (mut read_half, mut write_half) = stream.into_split();
let (tx, mut rx) = mpsc::channel::<Bytes>(queue_depth);
if keep_last {
if let Some(buf) = last.lock().await.clone() {
let _ = tx.try_send(buf);
}
}
let writer = tokio::spawn(async move {
while let Some(buf) = rx.recv().await {
if write_half.write_all(&buf).await.is_err() {
break;
}
if write_half.flush().await.is_err() {
break;
}
}
});
let reader = tokio::spawn(async move {
match inbound_tx {
Some(tx) => {
let mut peer = peer;
loop {
let mut header = [0u8; frame::HEADER_SIZE];
if read_half.read_exact(&mut header).await.is_err() {
break;
}
let len = match frame::decode_len(header, frame::MAVLINK_MAX_FRAME, false) {
Ok(n) => n,
Err(_) => break,
};
let mut payload = vec![0u8; len];
if len > 0 && read_half.read_exact(&mut payload).await.is_err() {
break;
}
if payload == IPC_DECLARE_OFF_BOX_SOURCE {
peer.off_box_source = true;
continue;
}
if let Some(claim) = parse_injector_declaration(&payload) {
peer.injector = Some(claim);
continue;
}
if tx
.send(InboundCommand {
payload,
peer: peer.clone(),
})
.await
.is_err()
{
break;
}
}
}
None => {
let mut scratch = [0u8; 256];
while read_half.read(&mut scratch).await.unwrap_or(0) > 0 {}
}
}
});
clients
.lock()
.await
.push(ClientHandle { tx, writer, reader });
}
pub async fn broadcast(&self, buf: Bytes) {
if self.keep_last {
*self.last.lock().await = Some(buf.clone());
}
let mut clients = self.clients.lock().await;
let mut keep = Vec::with_capacity(clients.len());
let mut dropped = 0u64;
for client in clients.drain(..) {
match client.tx.try_send(buf.clone()) {
Ok(()) => keep.push(client),
Err(mpsc::error::TrySendError::Full(_)) => {
client.reader.abort();
client.writer.abort();
dropped += 1;
}
Err(mpsc::error::TrySendError::Closed(_)) => {
client.reader.abort();
client.writer.abort();
}
}
}
*clients = keep;
if dropped > 0 {
self.dropped_clients.fetch_add(dropped, Ordering::Relaxed);
}
}
pub async fn client_count(&self) -> usize {
self.clients.lock().await.len()
}
pub fn queue_depth(&self) -> usize {
self.queue_depth
}
pub fn dropped_clients(&self) -> u64 {
self.dropped_clients.load(Ordering::Relaxed)
}
}
impl Drop for IpcBroadcast {
fn drop(&mut self) {
self.accept_task.abort();
if let Ok(clients) = self.clients.try_lock() {
for client in clients.iter() {
client.reader.abort();
client.writer.abort();
}
}
let _ = std::fs::remove_file(&self.path);
}
}
pub const OPERATOR_GROUP: &str = "ados-operator";
pub const CAPTURE_GROUP: &str = "ados-capture";
pub const SENSORS_GROUP: &str = "ados-sensors";
pub const PLUGIN_GROUP: &str = "ados-plugins";
pub fn operator_peer_allowed(
peer_uid: u32,
peer_gids: &[u32],
server_uid: u32,
operator_gid: Option<u32>,
) -> bool {
peer_uid == 0
|| peer_uid == server_uid
|| operator_gid.is_some_and(|gid| peer_gids.contains(&gid))
}
pub struct OperatorListener {
inner: UnixListener,
path: PathBuf,
server_uid: u32,
operator_gid: Option<u32>,
reader_gid: Option<u32>,
}
impl OperatorListener {
pub async fn accept(&self) -> io::Result<(UnixStream, tokio::net::unix::SocketAddr)> {
loop {
let (stream, addr) = self.inner.accept().await?;
if self.classify(&stream).is_some() {
return Ok((stream, addr));
}
}
}
pub async fn accept_classified(&self) -> io::Result<(UnixStream, PeerClass)> {
loop {
let (stream, _) = self.inner.accept().await?;
if let Some(class) = self.classify(&stream) {
return Ok((stream, class));
}
}
}
fn classify(&self, stream: &UnixStream) -> Option<PeerClass> {
match peer_credentials(stream) {
Ok((uid, gids)) => {
let class = classify_peer(
uid,
&gids,
self.server_uid,
self.operator_gid,
self.reader_gid,
);
if class.is_none() {
tracing::warn!(
path = %self.path.display(),
peer_uid = uid,
"command socket refused a peer outside the operator group"
);
}
class
}
Err(err) if peer_hung_up_before_accept(&err) => Some(if self.reader_gid.is_some() {
PeerClass::Reader
} else {
PeerClass::Operator
}),
Err(err) => {
tracing::warn!(
path = %self.path.display(),
error = %err,
"command socket refused a peer with unreadable credentials"
);
None
}
}
}
}
fn peer_credentials(stream: &UnixStream) -> io::Result<(u32, Vec<u32>)> {
let cred = stream.peer_cred()?;
#[allow(unused_mut)]
let mut gids = vec![cred.gid()];
#[cfg(target_os = "linux")]
gids.extend(peer_supplementary_groups(stream));
Ok((cred.uid(), gids))
}
pub fn is_operator_peer(stream: &UnixStream) -> bool {
match peer_credentials(stream) {
Ok((uid, gids)) => operator_peer_allowed(
uid,
&gids,
nix::unistd::geteuid().as_raw(),
group_gid(OPERATOR_GROUP),
),
Err(err) => peer_hung_up_before_accept(&err),
}
}
#[cfg(not(target_os = "linux"))]
fn peer_hung_up_before_accept(err: &io::Error) -> bool {
err.kind() == io::ErrorKind::NotConnected
}
#[cfg(target_os = "linux")]
fn peer_hung_up_before_accept(_err: &io::Error) -> bool {
false
}
#[cfg(target_os = "linux")]
fn peer_supplementary_groups(stream: &UnixStream) -> Vec<u32> {
use nix::libc;
use std::os::fd::AsRawFd;
const GID_SIZE: usize = std::mem::size_of::<libc::gid_t>();
let fd = stream.as_raw_fd();
let mut groups: Vec<libc::gid_t> = vec![0; 32];
for _ in 0..2 {
let mut len = (groups.len() * GID_SIZE) as libc::socklen_t;
let rc = unsafe {
libc::getsockopt(
fd,
libc::SOL_SOCKET,
libc::SO_PEERGROUPS,
groups.as_mut_ptr().cast(),
&mut len,
)
};
if rc == 0 {
groups.truncate(len as usize / GID_SIZE);
return groups;
}
let needed = len as usize / GID_SIZE;
if io::Error::last_os_error().raw_os_error() != Some(libc::ERANGE) || needed <= groups.len()
{
break;
}
groups.resize(needed, 0);
}
Vec::new()
}
fn pin_send_buffer(stream: &UnixStream) {
use nix::libc;
use std::os::fd::AsRawFd;
let size = IPC_CLIENT_SEND_BUFFER_BYTES as libc::c_int;
let rc = unsafe {
libc::setsockopt(
stream.as_raw_fd(),
libc::SOL_SOCKET,
libc::SO_SNDBUF,
std::ptr::from_ref(&size).cast(),
std::mem::size_of::<libc::c_int>() as libc::socklen_t,
)
};
if rc != 0 {
tracing::warn!(
error = %io::Error::last_os_error(),
requested = IPC_CLIENT_SEND_BUFFER_BYTES,
"ipc_client_sndbuf_set_failed"
);
}
}
pub fn bind_command_socket(path: impl AsRef<Path>, mode: u32) -> io::Result<OperatorListener> {
let path = path.as_ref();
let inner = bind_socket_file(path, mode)?;
let operator_gid = group_gid(OPERATOR_GROUP);
set_socket_group(path, OPERATOR_GROUP, operator_gid);
Ok(OperatorListener {
inner,
path: path.to_path_buf(),
server_uid: nix::unistd::geteuid().as_raw(),
operator_gid,
reader_gid: None,
})
}
mod peer_class;
pub use peer_class::{bind_reader_socket, classify_peer, PeerClass};
pub fn bind_plugin_socket(path: impl AsRef<Path>, mode: u32) -> io::Result<UnixListener> {
let path = path.as_ref();
let listener = bind_socket_file(path, mode)?;
set_socket_group(path, PLUGIN_GROUP, group_gid(PLUGIN_GROUP));
Ok(listener)
}
fn bind_socket_file(path: &Path, mode: u32) -> io::Result<UnixListener> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).ok();
}
let _ = std::fs::remove_file(path);
let listener = UnixListener::bind(path)?;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))?;
Ok(listener)
}
fn group_gid(name: &str) -> Option<u32> {
match nix::unistd::Group::from_name(name) {
Ok(group) => group.map(|g| g.gid.as_raw()),
Err(err) => {
tracing::debug!(error = %err, group = name, "resolving socket group failed");
None
}
}
}
#[cfg(target_os = "linux")]
fn set_socket_group(path: &Path, group: &str, gid: Option<u32>) {
let Some(gid) = gid else {
tracing::debug!(
group,
"socket group not present; leaving socket group as-is"
);
return;
};
if let Err(err) = nix::unistd::chown(path, None, Some(nix::unistd::Gid::from_raw(gid))) {
tracing::debug!(error = %err, path = %path.display(), group, "chgrp socket failed");
}
}
#[cfg(not(target_os = "linux"))]
fn set_socket_group(_path: &Path, _group: &str, _gid: Option<u32>) {}
pub async fn serve_rpc<H, Fut>(listener: OperatorListener, max_request: usize, handler: H)
where
H: Fn(Vec<u8>) -> Fut + Clone + Send + Sync + 'static,
Fut: Future<Output = Vec<u8>> + Send + 'static,
{
loop {
let mut stream = match listener.accept().await {
Ok((s, _addr)) => s,
Err(e) => {
tracing::warn!(error = %e, "command socket accept failed");
tokio::time::sleep(ACCEPT_RETRY_BACKOFF).await;
continue;
}
};
let handler = handler.clone();
tokio::spawn(async move {
let request = match read_newline_line(&mut stream, max_request).await {
Ok(Some(req)) => req,
Ok(None) | Err(_) => return,
};
let mut response = handler(request).await;
response.push(b'\n');
if stream.write_all(&response).await.is_err() {
return;
}
let _ = stream.flush().await;
});
}
}
pub async fn connect_with_retry(
path: impl AsRef<Path>,
retries: u32,
delay: Duration,
) -> io::Result<UnixStream> {
let path = path.as_ref();
let mut last_err: Option<io::Error> = None;
for attempt in 0..retries.max(1) {
match UnixStream::connect(path).await {
Ok(s) => return Ok(s),
Err(e) => {
last_err = Some(e);
if attempt + 1 < retries {
tokio::time::sleep(delay).await;
}
}
}
}
Err(last_err.unwrap_or_else(|| io::Error::new(io::ErrorKind::NotFound, "connect failed")))
}
pub async fn read_length_prefixed<R: AsyncReadExt + Unpin>(
reader: &mut R,
max: usize,
reject_zero: bool,
) -> io::Result<Option<Vec<u8>>> {
let mut header = [0u8; frame::HEADER_SIZE];
match reader.read_exact(&mut header).await {
Ok(_) => {}
Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => return Ok(None),
Err(e) => return Err(e),
}
let len = frame::decode_len(header, max, reject_zero)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?;
let mut payload = vec![0u8; len];
if len > 0 {
reader.read_exact(&mut payload).await?;
}
Ok(Some(payload))
}
pub async fn read_newline_line<R: AsyncReadExt + Unpin>(
reader: &mut R,
max: usize,
) -> io::Result<Option<Vec<u8>>> {
let mut buf = Vec::new();
let mut byte = [0u8; 1];
loop {
match reader.read_exact(&mut byte).await {
Ok(_) => {
if byte[0] == b'\n' {
return Ok(Some(buf));
}
if buf.len() >= max {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"state line exceeded maximum length without a newline",
));
}
buf.push(byte[0]);
}
Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => {
return if buf.is_empty() {
Ok(None)
} else {
Ok(Some(buf))
};
}
Err(e) => return Err(e),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::frame::{encode_frame, MAVLINK_MAX_FRAME};
fn temp_sock(name: &str) -> PathBuf {
let mut p = std::env::temp_dir();
let unique = format!("ados-ipc-test-{}-{}.sock", std::process::id(), name);
p.push(unique);
let _ = std::fs::remove_file(&p);
p
}
struct FlakyAcceptor {
listener: OperatorListener,
failures_left: usize,
}
impl ClientSource for FlakyAcceptor {
#[allow(clippy::manual_async_fn)]
fn next_client(&mut self) -> impl Future<Output = io::Result<UnixStream>> + Send {
async move {
if self.failures_left > 0 {
self.failures_left -= 1;
return Err(io::Error::from_raw_os_error(24));
}
self.listener.accept().await.map(|(s, _addr)| s)
}
}
}
#[tokio::test]
async fn the_accept_loop_survives_an_accept_error_and_still_serves_the_next_client() {
let path = temp_sock("acceptretry");
let listener = bind_command_socket(&path, 0o660).unwrap();
let (server, _inbound) = IpcBroadcast::from_source(
path.clone(),
FlakyAcceptor {
listener,
failures_left: 2,
},
256,
false,
None,
);
let mut client = connect_with_retry(&path, 200, Duration::from_millis(20))
.await
.unwrap();
tokio::time::sleep(ACCEPT_RETRY_BACKOFF * 3).await;
assert_eq!(server.client_count().await, 1);
server
.broadcast(
encode_frame(b"post-emfile", MAVLINK_MAX_FRAME)
.unwrap()
.into(),
)
.await;
let got = tokio::time::timeout(
Duration::from_secs(2),
read_length_prefixed(&mut client, MAVLINK_MAX_FRAME, false),
)
.await
.expect("a frame must reach a client accepted after an accept error")
.unwrap();
assert_eq!(got.as_deref(), Some(&b"post-emfile"[..]));
}
#[tokio::test]
async fn broadcasts_framed_payloads_to_all_clients() {
let path = temp_sock("bcast");
let (server, _inbound) = IpcBroadcast::bind(&path, 256, false, None).await.unwrap();
let mut c1 = connect_with_retry(&path, 10, Duration::from_millis(20))
.await
.unwrap();
let mut c2 = connect_with_retry(&path, 10, Duration::from_millis(20))
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(50)).await;
assert_eq!(server.client_count().await, 2);
server
.broadcast(encode_frame(b"hello", MAVLINK_MAX_FRAME).unwrap().into())
.await;
let f1 = read_length_prefixed(&mut c1, MAVLINK_MAX_FRAME, false)
.await
.unwrap();
let f2 = read_length_prefixed(&mut c2, MAVLINK_MAX_FRAME, false)
.await
.unwrap();
assert_eq!(f1.as_deref(), Some(&b"hello"[..]));
assert_eq!(f2.as_deref(), Some(&b"hello"[..]));
}
#[tokio::test]
async fn replays_last_state_on_connect() {
let path = temp_sock("laststate");
let (server, _inbound) = IpcBroadcast::bind(&path, 32, true, None).await.unwrap();
server
.broadcast(Bytes::from_static(b"{\"armed\":false}\n"))
.await;
let mut client = connect_with_retry(&path, 10, Duration::from_millis(20))
.await
.unwrap();
let line = read_newline_line(&mut client, crate::state::STATE_V2_MAX_FRAME)
.await
.unwrap();
assert_eq!(line.as_deref(), Some(&b"{\"armed\":false}"[..]));
}
#[tokio::test]
async fn slow_client_is_dropped_fast_client_survives() {
let path = temp_sock("slow");
let (server, _inbound) = IpcBroadcast::bind(&path, 2, false, None).await.unwrap();
let _slow = connect_with_retry(&path, 10, Duration::from_millis(20))
.await
.unwrap();
let mut fast = connect_with_retry(&path, 10, Duration::from_millis(20))
.await
.unwrap();
let fast_reader = tokio::spawn(async move {
while let Ok(Some(_)) = read_length_prefixed(&mut fast, MAVLINK_MAX_FRAME, false).await
{
}
});
tokio::time::sleep(Duration::from_millis(50)).await;
assert_eq!(server.client_count().await, 2);
let big = vec![0xABu8; 60_000];
for _ in 0..60u32 {
server
.broadcast(encode_frame(&big, MAVLINK_MAX_FRAME).unwrap().into())
.await;
tokio::time::sleep(Duration::from_millis(2)).await;
}
tokio::time::sleep(Duration::from_millis(50)).await;
assert_eq!(server.client_count().await, 1);
assert_eq!(server.dropped_clients(), 1);
fast_reader.abort();
}
#[tokio::test]
async fn dropped_client_counter_advances_when_a_consumer_stalls() {
let path = temp_sock("dropcount");
let (server, _inbound) = IpcBroadcast::bind(&path, 2, false, None).await.unwrap();
assert_eq!(server.dropped_clients(), 0);
let _stalled = connect_with_retry(&path, 10, Duration::from_millis(20))
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(50)).await;
assert_eq!(server.client_count().await, 1);
let big = vec![0x5Au8; 60_000];
for _ in 0..60u32 {
server
.broadcast(encode_frame(&big, MAVLINK_MAX_FRAME).unwrap().into())
.await;
tokio::time::sleep(Duration::from_millis(2)).await;
if server.client_count().await == 0 {
break;
}
}
tokio::time::sleep(Duration::from_millis(50)).await;
assert_eq!(server.client_count().await, 0);
assert_eq!(server.dropped_clients(), 1);
server
.broadcast(encode_frame(b"tail", MAVLINK_MAX_FRAME).unwrap().into())
.await;
assert_eq!(server.dropped_clients(), 1);
}
#[tokio::test]
async fn a_clean_disconnect_is_not_counted_as_a_slow_client() {
let path = temp_sock("cleandisconnect");
let (server, _inbound) = IpcBroadcast::bind(&path, 32, true, None).await.unwrap();
server
.broadcast(encode_frame(b"snapshot", MAVLINK_MAX_FRAME).unwrap().into())
.await;
for _ in 0..3u32 {
let client = connect_with_retry(&path, 10, Duration::from_millis(20))
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(30)).await;
drop(client);
tokio::time::sleep(Duration::from_millis(30)).await;
for _ in 0..2u32 {
server
.broadcast(encode_frame(b"tick", MAVLINK_MAX_FRAME).unwrap().into())
.await;
tokio::time::sleep(Duration::from_millis(20)).await;
}
}
assert_eq!(server.client_count().await, 0);
assert_eq!(server.dropped_clients(), 0);
}
#[tokio::test]
async fn inbound_frames_from_client_reach_the_channel() {
let path = temp_sock("inbound");
let (_server, inbound) = IpcBroadcast::bind(&path, 256, false, Some(16))
.await
.unwrap();
let mut inbound = inbound.expect("inbound channel requested");
let mut client = connect_with_retry(&path, 10, Duration::from_millis(20))
.await
.unwrap();
let framed = encode_frame(b"command", MAVLINK_MAX_FRAME).unwrap();
client.write_all(&framed).await.unwrap();
client.flush().await.unwrap();
let got = tokio::time::timeout(Duration::from_millis(500), inbound.recv())
.await
.unwrap()
.expect("a command");
assert_eq!(got.payload, b"command");
assert!(
got.peer.uid.is_some(),
"the accepting socket must record who wrote the payload"
);
assert!(!got.peer.off_box_source);
}
#[tokio::test]
async fn a_declared_off_box_source_sticks_and_is_not_forwarded() {
let path = temp_sock("offbox");
let (_server, inbound) = IpcBroadcast::bind(&path, 256, false, Some(16))
.await
.unwrap();
let mut inbound = inbound.expect("inbound channel requested");
let mut client = connect_with_retry(&path, 10, Duration::from_millis(20))
.await
.unwrap();
for payload in [IPC_DECLARE_OFF_BOX_SOURCE, b"first", b"second"] {
let framed = encode_frame(payload, MAVLINK_MAX_FRAME).unwrap();
client.write_all(&framed).await.unwrap();
}
client.flush().await.unwrap();
for expected in [&b"first"[..], &b"second"[..]] {
let got = tokio::time::timeout(Duration::from_millis(500), inbound.recv())
.await
.unwrap()
.expect("a command");
assert_eq!(got.payload, expected);
assert!(
got.peer.off_box_source,
"a declared forwarder must not read as a local producer"
);
}
}
#[test]
fn encode_injector_declaration_round_trips_through_the_parser() {
for (id, ticket) in [("ai-mission", Some("abc.def")), ("autopilot-1", None)] {
let frame = encode_injector_declaration(id, ticket);
assert!(frame.starts_with(IPC_DECLARE_INJECTOR_PREFIX));
assert_eq!(
parse_injector_declaration(&frame),
Some(InjectorClaim {
client_id: id.into(),
ticket: ticket.map(str::to_string),
})
);
}
}
#[test]
fn parse_injector_declaration_reads_id_and_optional_ticket() {
let mut d = IPC_DECLARE_INJECTOR_PREFIX.to_vec();
d.extend_from_slice(b"ai-mission");
assert_eq!(
parse_injector_declaration(&d),
Some(InjectorClaim {
client_id: "ai-mission".into(),
ticket: None
})
);
let mut d2 = IPC_DECLARE_INJECTOR_PREFIX.to_vec();
d2.extend_from_slice(b"ai-mission;ticket=abc.def");
assert_eq!(
parse_injector_declaration(&d2),
Some(InjectorClaim {
client_id: "ai-mission".into(),
ticket: Some("abc.def".into())
})
);
let mut empty = IPC_DECLARE_INJECTOR_PREFIX.to_vec();
empty.extend_from_slice(b";ticket=abc");
assert_eq!(parse_injector_declaration(&empty), None);
assert_eq!(parse_injector_declaration(b"command"), None);
assert_eq!(parse_injector_declaration(IPC_DECLARE_OFF_BOX_SOURCE), None);
}
#[tokio::test]
async fn a_declared_injector_sticks_and_is_not_forwarded() {
let path = temp_sock("injector");
let (_server, inbound) = IpcBroadcast::bind(&path, 256, false, Some(16))
.await
.unwrap();
let mut inbound = inbound.expect("inbound channel requested");
let mut client = connect_with_retry(&path, 10, Duration::from_millis(20))
.await
.unwrap();
let mut decl = IPC_DECLARE_INJECTOR_PREFIX.to_vec();
decl.extend_from_slice(b"ai-mission;ticket=tkt");
for payload in [decl.as_slice(), b"first", b"second"] {
let framed = encode_frame(payload, MAVLINK_MAX_FRAME).unwrap();
client.write_all(&framed).await.unwrap();
}
client.flush().await.unwrap();
for expected in [&b"first"[..], &b"second"[..]] {
let got = tokio::time::timeout(Duration::from_millis(500), inbound.recv())
.await
.unwrap()
.expect("a command");
assert_eq!(got.payload, expected);
let claim = got.peer.injector.expect("a declared injector claim");
assert_eq!(claim.client_id, "ai-mission");
assert_eq!(claim.ticket.as_deref(), Some("tkt"));
}
}
#[tokio::test]
async fn one_connection_declaring_does_not_mark_another() {
let path = temp_sock("offbox-scope");
let (_server, inbound) = IpcBroadcast::bind(&path, 256, false, Some(16))
.await
.unwrap();
let mut inbound = inbound.expect("inbound channel requested");
let mut declaring = connect_with_retry(&path, 10, Duration::from_millis(20))
.await
.unwrap();
declaring
.write_all(&encode_frame(IPC_DECLARE_OFF_BOX_SOURCE, MAVLINK_MAX_FRAME).unwrap())
.await
.unwrap();
declaring.flush().await.unwrap();
let mut plain = connect_with_retry(&path, 10, Duration::from_millis(20))
.await
.unwrap();
plain
.write_all(&encode_frame(b"local", MAVLINK_MAX_FRAME).unwrap())
.await
.unwrap();
plain.flush().await.unwrap();
let got = tokio::time::timeout(Duration::from_millis(500), inbound.recv())
.await
.unwrap()
.expect("a command");
assert_eq!(got.payload, b"local");
assert!(!got.peer.off_box_source);
}
#[tokio::test]
async fn read_newline_line_caps_an_endless_line() {
let (mut a, mut b) = UnixStream::pair().unwrap();
let writer = tokio::spawn(async move {
let chunk = vec![b'x'; 64];
for _ in 0..10 {
if a.write_all(&chunk).await.is_err() {
break;
}
let _ = a.flush().await;
}
tokio::time::sleep(Duration::from_secs(2)).await;
});
let err = read_newline_line(&mut b, 128).await.unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidData);
writer.abort();
}
#[tokio::test]
async fn bind_command_socket_removes_stale_and_sets_mode() {
let path = temp_sock("bindhelper");
std::fs::write(&path, b"stale").unwrap();
assert!(path.exists());
let listener = bind_command_socket(&path, 0o660).unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o660);
drop(listener);
let _ = std::fs::remove_file(&path);
}
#[tokio::test]
async fn a_broadcast_socket_is_never_world_accessible() {
let path = temp_sock("bcast-perm");
let (server, _inbound) = IpcBroadcast::bind(&path, 8, false, None).await.unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(
mode & 0o007,
0,
"broadcast socket must carry no world bits, got {:o}",
mode & 0o777
);
assert_eq!(mode & 0o777, 0o660, "expected owner+group rw only");
drop(server);
let _ = std::fs::remove_file(&path);
}
#[tokio::test]
async fn serve_rpc_round_trips_a_request_and_closes_over_cap() {
let path = temp_sock("rpc");
let listener = bind_command_socket(&path, 0o660).unwrap();
let server = tokio::spawn(serve_rpc(listener, 16, |req: Vec<u8>| async move {
let mut resp = b"echo:".to_vec();
resp.extend_from_slice(&req);
resp
}));
let mut client = connect_with_retry(&path, 10, Duration::from_millis(20))
.await
.unwrap();
client.write_all(b"ping\n").await.unwrap();
client.flush().await.unwrap();
let line = read_newline_line(&mut client, 256).await.unwrap();
assert_eq!(line.as_deref(), Some(&b"echo:ping"[..]));
drop(client);
let mut over = connect_with_retry(&path, 10, Duration::from_millis(20))
.await
.unwrap();
let _ = over.write_all(&[b'x'; 64]).await;
let _ = over.flush().await;
let outcome = read_newline_line(&mut over, 256).await;
assert!(
matches!(outcome, Ok(None) | Err(_)),
"over-cap request must get no reply, got {outcome:?}"
);
server.abort();
let _ = std::fs::remove_file(&path);
}
#[test]
fn plugin_plane_peers_are_injectors_whatever_they_declare() {
let plugin_gid = Some(980);
let legacy_user = Some(995);
assert!(is_plugin_peer(1201, &[980], legacy_user, plugin_gid));
assert!(is_plugin_peer(1201, &[1201, 980], legacy_user, plugin_gid));
assert!(is_plugin_peer(995, &[995], legacy_user, plugin_gid));
assert!(!is_plugin_peer(0, &[0], legacy_user, plugin_gid));
assert!(!is_plugin_peer(1000, &[1000, 990], legacy_user, plugin_gid));
assert!(!is_plugin_peer(1201, &[980], None, None));
assert_eq!(plugin_plane_claim(1201).ticket, None);
}
#[test]
fn operator_peer_policy_admits_root_the_server_uid_and_operator_members_only() {
let operator_gid = Some(990);
assert!(!operator_peer_allowed(1000, &[1000], 0, operator_gid));
assert!(operator_peer_allowed(1000, &[1000, 990], 0, operator_gid));
assert!(operator_peer_allowed(1000, &[990], 0, operator_gid));
assert!(operator_peer_allowed(0, &[0], 0, operator_gid));
assert!(operator_peer_allowed(501, &[20], 501, operator_gid));
assert!(!operator_peer_allowed(1000, &[1000, 990], 0, None));
}
#[tokio::test]
async fn a_refused_peer_is_closed_before_its_request_is_read() {
if nix::unistd::geteuid().is_root() {
return;
}
let path = temp_sock("refused");
let mut listener = bind_command_socket(&path, 0o660).unwrap();
listener.server_uid = u32::MAX - 1;
listener.operator_gid = None;
let reached = Arc::new(AtomicU64::new(0));
let counter = reached.clone();
let server = tokio::spawn(serve_rpc(listener, 64, move |_req: Vec<u8>| {
let counter = counter.clone();
async move {
counter.fetch_add(1, Ordering::Relaxed);
b"served".to_vec()
}
}));
let mut client = connect_with_retry(&path, 10, Duration::from_millis(20))
.await
.unwrap();
let _ = client.write_all(b"ping\n").await;
let _ = client.flush().await;
let outcome =
tokio::time::timeout(Duration::from_secs(2), read_newline_line(&mut client, 256))
.await
.expect("a refused peer must be closed, not left hanging");
assert!(
matches!(outcome, Ok(None) | Err(_)),
"a refused peer must get no reply, got {outcome:?}"
);
assert_eq!(
reached.load(Ordering::Relaxed),
0,
"the handler must never run"
);
server.abort();
let _ = std::fs::remove_file(&path);
}
}