[WIP] Daemon & CLI

This commit is contained in:
Alex-Emmet 2026-07-23 23:13:02 +02:00
commit 8b158108bb
100 changed files with 6519 additions and 1596 deletions

View file

@ -1,63 +1,113 @@
use crate::{CommandRouter, DaemonRuntime};
use crate::deployment::from_environment;
use crate::{CommandRouter, DaemonRuntime, DaemonServices};
use iota_ipc::{
ClientMessage, DaemonMessage, HelloAck, MIN_PROTOCOL_VERSION, PROTOCOL_VERSION, read_msg,
write_msg,
};
use iota_logger::log;
use std::io::Result;
use std::os::unix::fs::{FileTypeExt, MetadataExt, OpenOptionsExt};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::{env, os::fd::FromRawFd};
use std::{env, fs::File, os::fd::FromRawFd, os::unix::net::UnixListener as StdUnixListener};
use tokio::net::{UnixListener, UnixStream};
use tokio::sync::{broadcast, mpsc, watch};
use tokio::time::timeout;
use uuid::Uuid;
/// Per-client outbound queue capacity.
const CLIENT_CHANNEL_SIZE: usize = 256;
/// Maximum handshake retries before giving up.
const MAX_HANDSHAKE_RETRIES: u32 = 10;
const MAX_HANDSHAKE_RETRIES: u32 = 1;
const CLIENT_IO_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
pub struct IpcServer {
path: PathBuf,
listener: UnixListener,
runtime: Arc<DaemonRuntime>,
services: Arc<DaemonServices>,
log_tx: broadcast::Sender<DaemonMessage>,
state_rx: watch::Sender<iota_ipc::StateSnapshot>,
state_rx: watch::Receiver<iota_ipc::StateSnapshot>,
instance_id: String,
_instance_lock: File,
}
impl IpcServer {
pub fn new(
pub async fn bind(
path: impl Into<PathBuf>,
runtime: Arc<DaemonRuntime>,
services: Arc<DaemonServices>,
log_tx: broadcast::Sender<DaemonMessage>,
state_rx: watch::Sender<iota_ipc::StateSnapshot>,
) -> Self {
Self {
path: path.into(),
runtime,
log_tx,
state_rx,
}
}
pub async fn run(self) -> Result<()> {
if let Some(parent) = self.path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
state_rx: watch::Receiver<iota_ipc::StateSnapshot>,
) -> Result<Self> {
let path = path.into();
let listener = match activated_listener()? {
Some(listener) => listener,
None => {
remove_stale_socket(&self.path).await?;
UnixListener::bind(&self.path)?
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let lock_path = path.with_extension("sock.lock");
let lock = File::options()
.create(true)
.mode(0o600)
.read(true)
.write(true)
.open(lock_path)?;
let locked = unsafe {
libc::flock(
std::os::fd::AsRawFd::as_raw_fd(&lock),
libc::LOCK_EX | libc::LOCK_NB,
)
} == 0;
if !locked {
return Err(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
"another daemon instance is already running",
));
}
remove_stale_socket(&path).await?;
let listener = UnixListener::bind(&path)?;
let _ = tokio::fs::set_permissions(
&path,
std::os::unix::fs::PermissionsExt::from_mode(0o600),
)
.await;
return Ok(Self {
listener,
runtime,
services,
log_tx,
state_rx,
instance_id: Uuid::new_v4().to_string(),
_instance_lock: lock,
});
}
};
Ok(Self {
listener,
runtime,
services,
log_tx,
state_rx,
instance_id: Uuid::new_v4().to_string(),
_instance_lock: File::options().read(true).open("/dev/null")?,
})
}
pub async fn serve(self) -> Result<()> {
loop {
let (stream, _addr) = listener.accept().await?;
let (stream, _addr) = self.listener.accept().await?;
eprintln!("IPC client accepted");
let runtime = self.runtime.clone();
let services = self.services.clone();
let log_tx = self.log_tx.clone();
let state_rx = self.state_rx.clone();
let instance_id = self.instance_id.clone();
tokio::spawn(async move {
if let Err(error) = handle_client(stream, runtime, log_tx, state_rx).await {
if let Err(error) =
handle_client(stream, runtime, services, log_tx, state_rx, instance_id).await
{
eprintln!("IPC client error: {error}");
}
});
@ -77,13 +127,67 @@ fn activated_listener() -> Result<Option<UnixListener>> {
if listen_fds != Some(1) || listen_pid != Some(std::process::id()) {
return Ok(None);
}
let listener = unsafe { std::os::unix::net::UnixListener::from_raw_fd(3) };
UnixListener::from_std(listener).map(Some)
// SAFETY: systemd transfers ownership of the activated descriptor to us.
let listener = unsafe { StdUnixListener::from_raw_fd(3) };
into_tokio_listener(listener).map(Some)
}
fn into_tokio_listener(listener: StdUnixListener) -> Result<UnixListener> {
listener.set_nonblocking(true)?;
UnixListener::from_std(listener)
}
async fn write_client_message<W>(writer: &mut W, message: &DaemonMessage) -> Result<()>
where
W: tokio::io::AsyncWrite + Unpin,
{
timeout(CLIENT_IO_TIMEOUT, write_msg(writer, message))
.await
.map_err(|_| {
std::io::Error::new(std::io::ErrorKind::TimedOut, "IPC client write timed out")
})?
}
async fn remove_stale_socket(path: &Path) -> Result<()> {
match tokio::fs::symlink_metadata(path).await {
Ok(_) => tokio::fs::remove_file(path).await,
Ok(metadata) => {
if metadata.file_type().is_symlink() || !metadata.file_type().is_socket() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"IPC path exists but is not an owned Unix socket",
));
}
if metadata.uid() != unsafe { libc::geteuid() } as u32 {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"existing IPC socket is not owned by the current user",
));
}
match timeout(
std::time::Duration::from_millis(250),
UnixStream::connect(path),
)
.await
{
Ok(Ok(_)) => Err(std::io::Error::new(
std::io::ErrorKind::AddrInUse,
"an IPC daemon is already listening",
)),
Ok(Err(error))
if matches!(
error.kind(),
std::io::ErrorKind::ConnectionRefused | std::io::ErrorKind::NotFound
) =>
{
tokio::fs::remove_file(path).await
}
Ok(Err(error)) => Err(error),
Err(_) => Err(std::io::Error::new(
std::io::ErrorKind::TimedOut,
"could not determine whether the existing IPC socket is active",
)),
}
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error),
}
@ -93,10 +197,10 @@ async fn remove_stale_socket(path: &Path) -> Result<()> {
struct PeerIdentity {
pid: i32,
uid: u32,
gid: u32,
_gid: u32,
}
fn peer_credentials(stream: &UnixStream) -> PeerIdentity {
fn peer_credentials(stream: &UnixStream) -> Result<PeerIdentity> {
#[cfg(target_os = "linux")]
{
use std::os::unix::io::AsRawFd;
@ -104,70 +208,109 @@ fn peer_credentials(stream: &UnixStream) -> PeerIdentity {
let mut cred: libc::ucred = std::mem::zeroed();
let mut len = std::mem::size_of::<libc::ucred>() as libc::socklen_t;
let fd = stream.as_raw_fd();
libc::getsockopt(
if libc::getsockopt(
fd,
libc::SOL_SOCKET,
libc::SO_PEERCRED,
&mut cred as *mut _ as *mut libc::c_void,
&mut len,
);
PeerIdentity {
) != 0
{
return Err(std::io::Error::last_os_error());
}
Ok(PeerIdentity {
pid: cred.pid,
uid: cred.uid,
gid: cred.gid,
}
_gid: cred.gid,
})
}
}
#[cfg(not(target_os = "linux"))]
{
PeerIdentity {
Ok(PeerIdentity {
pid: 0,
uid: 0,
gid: 0,
}
_gid: 0,
})
}
}
async fn handle_client(
stream: UnixStream,
runtime: Arc<DaemonRuntime>,
services: Arc<DaemonServices>,
log_tx: broadcast::Sender<DaemonMessage>,
_state_rx: watch::Sender<iota_ipc::StateSnapshot>,
mut state_rx: watch::Receiver<iota_ipc::StateSnapshot>,
instance_id: String,
) -> Result<()> {
let peer = peer_credentials(&stream);
let peer = peer_credentials(&stream)?;
// Access control belongs to the Unix socket. The systemd socket grants
// iota-operators group access (0660); rejecting every UID other than the
// service account here would make that authorization ineffective. Manual
// sockets remain owner-only (0600) at bind time.
let (mut reader, mut writer) = stream.into_split();
// A failed writer must stop the reader and any subsequent command work
// for this client; otherwise the reader can remain parked forever.
let session_cancellation = runtime.cancellation.child_token();
let (directed_tx, directed_rx) = mpsc::channel::<DaemonMessage>(CLIENT_CHANNEL_SIZE);
eprintln!("IPC handshake started (pid={}, uid={})", peer.pid, peer.uid);
// --- Handshake ---
let mut negotiated_version: Option<u16> = None;
for _ in 0..MAX_HANDSHAKE_RETRIES {
match read_msg::<_, ClientMessage>(&mut reader).await {
Ok(ClientMessage::Hello { supported_versions }) => {
let version = supported_versions
.iter()
.copied()
.find(|v| *v >= MIN_PROTOCOL_VERSION && *v <= PROTOCOL_VERSION)
.unwrap_or(PROTOCOL_VERSION);
negotiated_version = Some(version);
let instance_id = Uuid::new_v4().to_string();
let ack = DaemonMessage::HelloAck(HelloAck {
protocol_version: version,
daemon_version: env!("CARGO_PKG_VERSION").to_string(),
instance_id,
startup_phase: runtime.current_startup_phase().into(),
capabilities: vec!["commands".into(), "metrics".into(), "logs".into()],
});
write_msg(&mut writer, &ack).await?;
break;
}
Ok(_) => {
// Unexpected first message — send error and close.
match timeout(
std::time::Duration::from_secs(15),
read_msg::<_, ClientMessage>(&mut reader),
)
.await
{
Err(_) => {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"Expected Hello as first message",
std::io::ErrorKind::TimedOut,
"IPC Hello timed out",
));
}
Err(e) => return Err(e),
Ok(result) => match result {
Ok(ClientMessage::Hello { supported_versions }) => {
let version = supported_versions
.iter()
.copied()
.filter(|v| *v >= MIN_PROTOCOL_VERSION && *v <= PROTOCOL_VERSION)
.max()
.ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::Unsupported,
"No compatible IPC protocol version",
)
})?;
negotiated_version = Some(version);
let ack = DaemonMessage::HelloAck(HelloAck {
protocol_version: version,
daemon_version: env!("CARGO_PKG_VERSION").to_string(),
instance_id: instance_id.clone(),
startup_phase: runtime.current_startup_phase().into(),
capabilities: vec!["commands".into(), "metrics".into(), "logs".into()],
lifecycle: *runtime.lifecycle.borrow(),
health: runtime.overall_health(),
deployment_mode: from_environment().mode,
supervisor: from_environment().supervisor,
});
write_client_message(&mut writer, &ack).await?;
eprintln!(
"IPC handshake acknowledged (pid={}, uid={})",
peer.pid, peer.uid
);
break;
}
Ok(_) => {
// Unexpected first message — send error and close.
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"Expected Hello as first message",
));
}
Err(e) => return Err(e),
},
}
}
let _version = negotiated_version.ok_or_else(|| {
@ -182,9 +325,9 @@ async fn handle_client(
// --- Writer task: merge directed responses + shared log events ---
let mut log_rx = log_tx.subscribe();
let directed_for_writer = directed_tx.clone();
let writer_task = {
let runtime = runtime.clone();
let session_cancellation = session_cancellation.clone();
tokio::spawn(async move {
let mut directed_rx = directed_rx;
loop {
@ -193,7 +336,9 @@ async fn handle_client(
msg = directed_rx.recv() => {
match msg {
Some(message) => {
if write_msg(&mut writer, &message).await.is_err() {
if let Err(error) = write_client_message(&mut writer, &message).await {
eprintln!("IPC client writer stopped while sending directed message: {error}");
session_cancellation.cancel();
break;
}
}
@ -204,36 +349,91 @@ async fn handle_client(
result = log_rx.recv() => {
match result {
Ok(message) => {
if write_msg(&mut writer, &message).await.is_err() {
if let Err(error) = write_client_message(&mut writer, &message).await {
eprintln!("IPC client writer stopped while sending log message: {error}");
session_cancellation.cancel();
break;
}
}
Err(broadcast::error::RecvError::Lagged(skipped)) => {
let _ = directed_for_writer.send(DaemonMessage::Gap { skipped }).await;
// Then send current snapshot for resync
let _ = directed_for_writer.send(
DaemonMessage::StateUpdate(runtime.snapshot())
).await;
if write_client_message(&mut writer, &DaemonMessage::Gap { skipped }).await.is_err()
|| write_client_message(&mut writer, &DaemonMessage::StateUpdate(runtime.snapshot())).await.is_err()
{
session_cancellation.cancel();
break;
}
}
Err(broadcast::error::RecvError::Closed) => break,
}
}
changed = state_rx.changed() => {
if changed.is_err() {
break;
}
let snapshot = state_rx.borrow().clone();
if let Err(error) = write_client_message(&mut writer, &DaemonMessage::StateUpdate(snapshot)).await {
eprintln!("IPC client writer stopped while sending state update: {error}");
session_cancellation.cancel();
break;
}
}
}
}
})
};
// --- Reader loop ---
let router = CommandRouter::new(runtime.clone());
let router = CommandRouter::new(runtime.clone(), services);
loop {
match read_msg::<_, ClientMessage>(&mut reader).await {
let message = tokio::select! {
_ = session_cancellation.cancelled() => break,
result = read_msg::<_, ClientMessage>(&mut reader) => result,
};
match message {
Ok(ClientMessage::Request(envelope)) => {
let response = router.route(envelope.request_id, envelope.request).await;
let shutdown_reason = match &envelope.request {
iota_ipc::LocalRequest::RequestProcessExit {
intent: iota_ipc::ExitIntent::Restart,
}
| iota_ipc::LocalRequest::RestartDaemon => Some("restart requested"),
iota_ipc::LocalRequest::RequestProcessExit {
intent: iota_ipc::ExitIntent::Stop,
} => Some("shutdown requested"),
iota_ipc::LocalRequest::StopDaemon => Some("shutdown requested"),
_ => None,
};
let response = if envelope.protocol_version < MIN_PROTOCOL_VERSION
|| envelope.protocol_version > PROTOCOL_VERSION
{
iota_ipc::ResponseEnvelope {
request_id: envelope.request_id,
result: iota_ipc::ResponseResult::Error(
iota_ipc::IpcErrorCode::UnsupportedVersion,
),
}
} else {
router.route(envelope.request_id, envelope.request).await
};
let _ = directed_tx.send(DaemonMessage::Response(response)).await;
if let Some(reason) = shutdown_reason {
let _ = directed_tx
.send(DaemonMessage::LifecycleEvent(
iota_ipc::LifecycleEvent::Shutdown {
reason: reason.into(),
},
))
.await;
// The request itself initiates daemon cancellation. Give
// the dedicated writer a chance to flush the response
// and lifecycle event before this session is torn down.
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
break;
}
}
Ok(ClientMessage::Subscribe { .. }) => {
let snapshot = DaemonMessage::StateUpdate(runtime.snapshot());
let _ = directed_tx.send(snapshot).await;
let _ = directed_tx.send(DaemonMessage::Subscribed).await;
}
Ok(ClientMessage::Ping { seq }) => {
let _ = directed_tx.send(DaemonMessage::Pong { seq }).await;
@ -250,6 +450,7 @@ async fn handle_client(
}
}
}
session_cancellation.cancel();
writer_task.abort();
log!(
"IPC client disconnected (pid={}, uid={})",
@ -258,3 +459,25 @@ async fn handle_client(
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[tokio::test(flavor = "current_thread")]
async fn converted_listener_does_not_block_the_runtime() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("ipc.sock");
let listener = match StdUnixListener::bind(path) {
Ok(listener) => into_tokio_listener(listener).unwrap(),
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => return,
Err(error) => panic!("could not create test socket: {error}"),
};
assert!(
tokio::time::timeout(Duration::from_millis(50), listener.accept())
.await
.is_err()
);
}
}