use crate::control::command::CommandResult;
use crate::control::handle::SupervisorHandle;
use crate::dashboard::config::ValidatedDashboardIpcConfig;
use crate::dashboard::error::DashboardError;
use crate::dashboard::model::{
ControlCommandKind, ControlCommandRequest, ControlCommandResult, DashboardCurrentState,
DashboardState, TargetProcessRegistration, dashboard_command_result_value,
runtime_state_from_child_runtime_record,
};
use crate::dashboard::protocol::{
DASHBOARD_IPC_PROTOCOL_VERSION, IpcMethod, IpcRequest, IpcResponse, IpcResult,
decode_command_params,
};
use crate::dashboard::registration::build_registration_payload;
use crate::dashboard::runtime::DEFAULT_MAX_FRAME_BYTES;
use crate::dashboard::state::{DashboardStateInput, build_dashboard_state};
use crate::id::types::{ChildId, SupervisorPath};
use crate::ipc::security::peer_identity::PeerIdentity;
use crate::ipc::security::{CheckOutcome, IpcSecurityPipeline};
use crate::journal::ring::EventJournal;
use crate::spec::supervisor::SupervisorSpec;
use crate::state::supervisor::SupervisorState;
use std::os::unix::fs::{FileTypeExt, PermissionsExt};
use std::os::unix::net::UnixStream as StdUnixStream;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use tokio::net::UnixListener;
pub struct DashboardIpcService {
config: ValidatedDashboardIpcConfig,
spec: SupervisorSpec,
state: SupervisorState,
journal: EventJournal,
handle: Option<SupervisorHandle>,
state_generation: AtomicU64,
security_pipeline: Option<Arc<Mutex<IpcSecurityPipeline>>>,
}
impl DashboardIpcService {
pub fn new(
config: ValidatedDashboardIpcConfig,
spec: SupervisorSpec,
state: SupervisorState,
journal: EventJournal,
) -> Self {
Self {
config,
spec,
state,
journal,
handle: None,
state_generation: AtomicU64::new(1),
security_pipeline: None,
}
}
pub fn with_handle(mut self, handle: SupervisorHandle) -> Self {
self.handle = Some(handle);
self
}
pub fn with_security_pipeline(mut self, pipeline: IpcSecurityPipeline) -> Self {
self.security_pipeline = Some(Arc::new(Mutex::new(pipeline)));
self
}
pub fn max_frame_bytes(&self) -> usize {
self.security_pipeline
.as_ref()
.and_then(|p| p.lock().ok())
.and_then(|guard| guard.request_size_limit_bytes())
.unwrap_or(DEFAULT_MAX_FRAME_BYTES)
}
pub fn registration_payload(&self) -> Result<TargetProcessRegistration, DashboardError> {
build_registration_payload(&self.config)
}
pub async fn handle_request(
&self,
request: IpcRequest,
peer: &PeerIdentity,
connection_id: &str,
raw_body_len: usize,
) -> IpcResponse {
let method = request.method.clone();
let request_id = request.request_id.clone();
let is_high_risk = is_high_risk_command(&method, self);
if let Some(ref pipeline) = self.security_pipeline {
let mut guard = match pipeline.lock() {
Ok(guard) => guard,
Err(poisoned) => {
tracing::error!(
target: "rust_supervisor::ipc::security",
%method,
"pipeline mutex poisoned, recovering"
);
poisoned.into_inner()
}
};
match guard.check(&method, raw_body_len, peer, connection_id) {
CheckOutcome::Denied(err) => {
let err_code = err.code.clone();
let _ = self.audit_or_fail(
&mut guard,
&method,
peer,
false,
Some(&err),
&err_code,
is_high_risk,
&request_id,
);
return IpcResponse::error(request.request_id.clone(), err);
}
CheckOutcome::Passed => {}
}
if let Some(cached_json) = guard.check_idempotency(&request_id) {
let method = method.clone();
let peer_clone = peer.clone();
drop(guard);
if let Some(ref pipeline) = self.security_pipeline {
let mut guard = match pipeline.lock() {
Ok(guard) => guard,
Err(poisoned) => {
tracing::error!(
target: "rust_supervisor::ipc::security",
%method,
"pipeline mutex poisoned during C8 audit"
);
poisoned.into_inner()
}
};
let _ = self.audit_or_fail(
&mut guard,
&method,
&peer_clone,
true,
None,
"c8_idempotency_cache_hit",
is_high_risk,
&request_id,
);
}
return serde_json::from_str(&cached_json).unwrap_or_else(|_| {
IpcResponse::error(
request_id,
DashboardError::new(
"idempotency_cache_corrupted",
"c8_idempotency",
Some(self.config.target_id.clone()),
"cached response failed to deserialize".to_owned(),
false,
),
)
});
}
if let Err(err) = guard.check_replay_and_record(&request_id) {
let err_code = err.code.clone();
let _ = self.audit_or_fail(
&mut guard,
&method,
peer,
false,
Some(&err),
&err_code,
is_high_risk,
&request_id,
);
return IpcResponse::error(request.request_id.clone(), err);
}
drop(guard);
}
let dispatch_result = self.dispatch(&request).await;
let mut response = match &dispatch_result {
Ok(result) => IpcResponse::ok(request.request_id.clone(), result.clone()),
Err(error) => IpcResponse::error(request.request_id.clone(), error.clone()),
};
if let Some(ref pipeline) = self.security_pipeline {
let mut guard = match pipeline.lock() {
Ok(guard) => guard,
Err(poisoned) => {
tracing::error!(
target: "rust_supervisor::ipc::security",
%method,
"pipeline mutex poisoned during post-dispatch"
);
poisoned.into_inner()
}
};
if let Ok(response_json) = serde_json::to_string(&response) {
guard.cache_result(&request_id, &response_json);
}
let (allowed, denial_error, denial_code): (bool, Option<&DashboardError>, &str) =
match &dispatch_result {
Ok(_) => (true, None, "dispatch_ok"),
Err(err) => (false, Some(err), err.code.as_str()),
};
if let Err(audit_err) = self.audit_or_fail(
&mut guard,
&method,
peer,
allowed,
denial_error,
denial_code,
is_high_risk,
&request_id,
) && is_high_risk
{
tracing::error!(
target: "rust_supervisor::ipc::security::audit",
%method,
%request_id,
?audit_err,
"HIGH-RISK command denied because audit write failed (fail-closed)"
);
response = IpcResponse::error(request.request_id.clone(), audit_err);
}
}
response
}
#[allow(clippy::too_many_arguments)]
fn audit_or_fail(
&self,
guard: &mut std::sync::MutexGuard<'_, IpcSecurityPipeline>,
method: &str,
peer: &PeerIdentity,
allowed: bool,
denial_error: Option<&DashboardError>,
denial_code: &str,
is_high_risk: bool,
request_id: &str,
) -> Result<(), DashboardError> {
guard
.write_audit(
method,
peer,
allowed,
denial_error,
denial_code,
is_high_risk,
)
.map_err(|err| {
tracing::error!(
target: "rust_supervisor::ipc::security::audit",
%method,
%request_id,
high_risk = is_high_risk,
?err,
"audit write failed"
);
err
})
}
async fn dispatch(&self, request: &IpcRequest) -> Result<IpcResult, DashboardError> {
let method = IpcMethod::parse(&request.method)?;
match method {
IpcMethod::Hello => Ok(IpcResult::Hello {
protocol_version: DASHBOARD_IPC_PROTOCOL_VERSION.to_owned(),
registration: self.registration_payload()?,
}),
IpcMethod::CurrentState => {
let state = self.current_dashboard_state().await?;
Ok(IpcResult::State {
target_id: state.target.target_id.clone(),
state: Box::new(state),
})
}
IpcMethod::EventsSubscribe => {
require_session_trigger(request, &self.config.target_id, self)?;
Ok(IpcResult::Subscription {
target_id: self.config.target_id.clone(),
subscription: "events".to_owned(),
})
}
IpcMethod::LogsTail => {
require_session_trigger(request, &self.config.target_id, self)?;
Ok(IpcResult::Subscription {
target_id: self.config.target_id.clone(),
subscription: "logs".to_owned(),
})
}
IpcMethod::CommandRestartChild
| IpcMethod::CommandPauseChild
| IpcMethod::CommandResumeChild
| IpcMethod::CommandQuarantineChild
| IpcMethod::CommandRemoveChild
| IpcMethod::CommandAddChild
| IpcMethod::CommandShutdownTree => self.command_result(request).await,
}
}
pub async fn current_dashboard_state(&self) -> Result<DashboardState, DashboardError> {
let generation = self.state_generation.load(Ordering::Relaxed);
let registration = self.registration_payload().ok();
let mut state = build_dashboard_state(
DashboardStateInput {
target_id: self.config.target_id.clone(),
display_name: registration
.as_ref()
.map(|registration| registration.display_name.clone())
.unwrap_or_else(|| self.config.target_id.clone()),
state_generation: generation,
recent_limit: 128,
},
&self.spec,
&self.state,
&self.journal,
);
if let Some(handle) = self.handle.as_ref() {
let result = handle.current_state().await.map_err(|error| {
DashboardError::new(
"current_state_failed",
"state",
Some(self.config.target_id.clone()),
error.to_string(),
true,
)
})?;
if let CommandResult::CurrentState {
state: runtime_state,
} = result
{
let dashboard_state = DashboardCurrentState::from_current_state(&runtime_state);
state.runtime_state = runtime_state
.child_runtime_records
.iter()
.map(|record| {
runtime_state_from_child_runtime_record(
record,
runtime_state.shutdown_completed,
)
})
.collect();
state.child_runtime_records = dashboard_state.child_runtime_records;
}
}
Ok(state)
}
async fn command_result(&self, request: &IpcRequest) -> Result<IpcResult, DashboardError> {
let command = decode_command_params(request)?;
validate_command(&command)?;
if command.target_id != self.config.target_id {
return Err(DashboardError::validation(
"command_validate",
Some(self.config.target_id.clone()),
"command target_id must match target process",
));
}
let result = if let Some(handle) = self.handle.as_ref() {
execute_command(handle, &command).await
} else {
Err(DashboardError::target_unavailable(
"command_dispatch",
command.target_id.clone(),
"runtime control handle is not attached",
))
};
let _ = self.state_generation.fetch_add(1, Ordering::Relaxed);
let result = match result {
Ok(result) => {
let state_delta = dashboard_command_result_value(&result).map_err(|error| {
DashboardError::new(
"command_result_model_failed",
"command_dispatch",
Some(command.target_id.clone()),
format!("failed to map command result: {error}"),
false,
)
})?;
ControlCommandResult {
command_id: command.command_id.clone(),
target_id: command.target_id.clone(),
accepted: true,
status: "completed".to_owned(),
error: None,
state_delta: Some(state_delta),
completed_at_unix_nanos: Some(unix_nanos_now()),
}
}
Err(error) => ControlCommandResult {
command_id: command.command_id.clone(),
target_id: command.target_id.clone(),
accepted: false,
status: "failed".to_owned(),
error: Some(error),
state_delta: None,
completed_at_unix_nanos: Some(unix_nanos_now()),
},
};
Ok(IpcResult::CommandResult {
target_id: command.target_id,
result,
})
}
}
pub fn bind_dashboard_listener(
config: &ValidatedDashboardIpcConfig,
) -> Result<UnixListener, DashboardError> {
prepare_socket_path(config)?;
if let Some(parent) = config.path.parent() {
std::fs::create_dir_all(parent).map_err(|error| {
DashboardError::new(
"ipc_parent_dir_creation_failed",
"ipc_bind",
Some(config.target_id.clone()),
format!("failed to create IPC parent directory: {error}"),
false,
)
})?;
}
let listener = UnixListener::bind(&config.path).map_err(|error| {
DashboardError::new(
"ipc_bind_failed",
"ipc_bind",
Some(config.target_id.clone()),
format!("failed to bind target IPC socket: {error}"),
true,
)
})?;
let mode = parse_permissions_string(&config.permissions, &config.target_id)?;
set_socket_permissions(&config.path, mode, &config.target_id)?;
Ok(listener)
}
fn parse_permissions_string(perm_str: &str, target_id: &str) -> Result<u32, DashboardError> {
if perm_str.len() != 4 || !perm_str.bytes().all(|b| b.is_ascii_digit()) {
return Err(DashboardError::validation(
"ipc_bind",
Some(target_id.to_owned()),
format!("dashboard.permissions must be a 4-digit octal string, got \"{perm_str}\""),
));
}
let mode = u32::from_str_radix(perm_str, 8).map_err(|_| {
DashboardError::validation(
"ipc_bind",
Some(target_id.to_owned()),
format!("dashboard.permissions \"{perm_str}\" is not valid octal"),
)
})?;
if mode & 0o002 != 0 {
return Err(DashboardError::validation(
"ipc_bind",
Some(target_id.to_owned()),
format!(
"dashboard.permissions \"{perm_str}\" grants world-write access, \
which is not allowed for Unix domain sockets"
),
));
}
Ok(mode)
}
fn set_socket_permissions(
path: &std::path::Path,
mode: u32,
target_id: &str,
) -> Result<(), DashboardError> {
let permissions = std::fs::Permissions::from_mode(mode);
std::fs::set_permissions(path, permissions).map_err(|error| {
DashboardError::new(
"ipc_set_permissions_failed",
"ipc_bind",
Some(target_id.to_owned()),
format!("failed to set socket permissions to {mode:#o}: {error}"),
true,
)
})
}
fn prepare_socket_path(config: &ValidatedDashboardIpcConfig) -> Result<(), DashboardError> {
let metadata = match std::fs::symlink_metadata(&config.path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(error) => {
return Err(DashboardError::new(
"ipc_path_metadata_failed",
"ipc_bind",
Some(config.target_id.clone()),
format!("failed to inspect IPC path: {error}"),
false,
));
}
};
match config.bind_mode {
crate::config::configurable::DashboardIpcBindMode::CreateNew => {
Err(DashboardError::validation(
"ipc_bind",
Some(config.target_id.clone()),
"IPC path already exists and bind_mode is create_new",
))
}
crate::config::configurable::DashboardIpcBindMode::ReplaceStale => {
if metadata.file_type().is_symlink() {
return Err(DashboardError::validation(
"ipc_bind",
Some(config.target_id.clone()),
"IPC path must not be a symlink",
));
}
if !metadata.file_type().is_socket() {
return Err(DashboardError::validation(
"ipc_bind",
Some(config.target_id.clone()),
"IPC path must be a Unix socket before stale replacement",
));
}
if StdUnixStream::connect(&config.path).is_ok() {
return Err(DashboardError::validation(
"ipc_bind",
Some(config.target_id.clone()),
"IPC path is served by a live process",
));
}
crate::ipc::security::peer_identity::prepare_socket_path_for_bind(&config.path)?;
std::fs::remove_file(&config.path).map_err(|error| {
DashboardError::new(
"ipc_stale_remove_failed",
"ipc_bind",
Some(config.target_id.clone()),
format!("failed to remove stale IPC path: {error}"),
true,
)
})
}
}
}
fn require_session_trigger(
request: &IpcRequest,
target_id: &str,
service: &DashboardIpcService,
) -> Result<(), DashboardError> {
if service.security_pipeline.is_none() {
return Err(DashboardError::new(
"session_required",
"subscription",
Some(target_id.to_owned()),
"event and log subscription require a security pipeline; \
without one the session_established flag cannot be verified",
false,
));
}
let established = request
.params
.get("session_established")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
if established {
Ok(())
} else {
Err(DashboardError::new(
"session_required",
"subscription",
Some(target_id.to_owned()),
"event and log subscription must be triggered by an established dashboard session",
false,
))
}
}
pub fn validate_command(command: &ControlCommandRequest) -> Result<(), DashboardError> {
if command.reason.trim().is_empty() {
return Err(DashboardError::validation(
"command_validate",
Some(command.target_id.clone()),
"command reason must not be empty",
));
}
if command.requested_by.trim().is_empty() {
return Err(DashboardError::validation(
"command_validate",
Some(command.target_id.clone()),
"requested_by must be derived by relay",
));
}
if uuid::Uuid::parse_str(&command.command_id).is_err() {
return Err(DashboardError::validation(
"command_validate",
Some(command.target_id.clone()),
format!(
"command_id is not a valid UUID: {id}",
id = command.command_id
),
));
}
if matches!(command.command, ControlCommandKind::AddChild) {
let has_manifest = command
.target
.child_manifest
.as_ref()
.is_some_and(|m| !m.trim().is_empty());
if !has_manifest {
return Err(DashboardError::validation(
"command_validate",
Some(command.target_id.clone()),
"add_child command requires a non-empty child_manifest",
));
}
}
if matches!(
command.command,
ControlCommandKind::RestartChild
| ControlCommandKind::PauseChild
| ControlCommandKind::ResumeChild
| ControlCommandKind::QuarantineChild
| ControlCommandKind::RemoveChild
) && command
.target
.child_path
.as_ref()
.is_none_or(|p| p.trim().is_empty())
{
return Err(DashboardError::validation(
"command_validate",
Some(command.target_id.clone()),
format!(
"{:?} command requires a non-empty child_path",
command.command
),
));
}
if command.requested_at_unix_nanos == 0 {
return Err(DashboardError::validation(
"command_validate",
Some(command.target_id.clone()),
"requested_at_unix_nanos must not be 0",
));
}
if matches!(
command.command,
ControlCommandKind::ShutdownTree
| ControlCommandKind::RemoveChild
| ControlCommandKind::AddChild
) && !command.confirmed
{
return Err(DashboardError::validation(
"command_validate",
Some(command.target_id.clone()),
"dangerous command requires confirmation",
));
}
Ok(())
}
const COMMAND_TIMEOUT_SECS: u64 = 30;
async fn execute_command(
handle: &SupervisorHandle,
command: &ControlCommandRequest,
) -> Result<CommandResult, DashboardError> {
tokio::time::timeout(
std::time::Duration::from_secs(COMMAND_TIMEOUT_SECS),
execute_command_inner(handle, command),
)
.await
.map_err(|_elapsed| {
DashboardError::new(
"command_timeout",
"command_dispatch",
Some(command.target_id.clone()),
format!(
"command {:?} timed out after {}s",
command.command, COMMAND_TIMEOUT_SECS,
),
true,
)
})?
}
async fn execute_command_inner(
handle: &SupervisorHandle,
command: &ControlCommandRequest,
) -> Result<CommandResult, DashboardError> {
let command_id = command
.command_id
.parse::<uuid::Uuid>()
.expect("command_id already validated as legal UUID");
let meta = crate::control::command::CommandMeta::with_id(
crate::control::command::CommandId::from_uuid(command_id),
&command.requested_by,
&command.reason,
);
let result = match command.command {
ControlCommandKind::RestartChild => {
handle
.execute_with_command_id(crate::control::command::ControlCommand::RestartChild {
meta: meta.clone(),
child_id: child_id(command)?,
})
.await
}
ControlCommandKind::PauseChild => {
handle
.execute_with_command_id(crate::control::command::ControlCommand::PauseChild {
meta: meta.clone(),
child_id: child_id(command)?,
})
.await
}
ControlCommandKind::ResumeChild => {
handle
.execute_with_command_id(crate::control::command::ControlCommand::ResumeChild {
meta: meta.clone(),
child_id: child_id(command)?,
})
.await
}
ControlCommandKind::QuarantineChild => {
handle
.execute_with_command_id(crate::control::command::ControlCommand::QuarantineChild {
meta: meta.clone(),
child_id: child_id(command)?,
})
.await
}
ControlCommandKind::RemoveChild => {
handle
.execute_with_command_id(crate::control::command::ControlCommand::RemoveChild {
meta: meta.clone(),
child_id: child_id(command)?,
})
.await
}
ControlCommandKind::AddChild => {
handle
.execute_with_command_id(crate::control::command::ControlCommand::AddChild {
meta: meta.clone(),
target: SupervisorPath::root(),
child_manifest: command.target.child_manifest.clone().unwrap_or_default(),
})
.await
}
ControlCommandKind::ShutdownTree => {
handle
.execute_with_command_id(crate::control::command::ControlCommand::ShutdownTree {
meta: meta.clone(),
})
.await
}
};
result.map_err(|error| {
DashboardError::new(
"command_failed",
"command_dispatch",
Some(command.target_id.clone()),
error.to_string(),
true,
)
})
}
fn child_id(command: &ControlCommandRequest) -> Result<ChildId, DashboardError> {
let child_path = command.target.child_path.as_deref().ok_or_else(|| {
DashboardError::validation(
"command_validate",
Some(command.target_id.clone()),
"child_path is required for child command",
)
})?;
let value = child_path
.rsplit('/')
.find(|segment| !segment.is_empty())
.unwrap_or(child_path);
Ok(ChildId::new(value))
}
fn unix_nanos_now() -> u128 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or(std::time::Duration::ZERO)
.as_nanos()
}
fn is_high_risk_command(method: &str, service: &DashboardIpcService) -> bool {
if let Some(ref pipeline) = service.security_pipeline
&& let Ok(guard) = pipeline.lock()
{
let configured = guard.high_risk_methods();
if !configured.is_empty() {
return configured.iter().any(|m| m == method);
}
}
matches!(
method,
"command.restart_child"
| "command.pause_child"
| "command.resume_child"
| "command.quarantine_child"
| "command.remove_child"
| "command.shutdown_tree"
| "command.add_child"
)
}