Skip to main content

rust_supervisor/dashboard/
ipc_server.rs

1//! Target-side dashboard IPC service.
2//!
3//! This module provides the target process dispatcher behind a local Unix
4//! domain socket. The service can be tested without a socket and can be bound to
5//! a socket by runtime code that owns process lifecycle.
6
7use crate::control::command::CommandResult;
8use crate::control::handle::SupervisorHandle;
9use crate::dashboard::config::ValidatedDashboardIpcConfig;
10use crate::dashboard::error::DashboardError;
11use crate::dashboard::model::{
12    ControlCommandKind, ControlCommandRequest, ControlCommandResult, DashboardCurrentState,
13    DashboardState, TargetProcessRegistration, dashboard_command_result_value,
14    runtime_state_from_child_runtime_record,
15};
16use crate::dashboard::protocol::{
17    DASHBOARD_IPC_PROTOCOL_VERSION, IpcMethod, IpcRequest, IpcResponse, IpcResult,
18    decode_command_params,
19};
20use crate::dashboard::registration::build_registration_payload;
21use crate::dashboard::runtime::DEFAULT_MAX_FRAME_BYTES;
22use crate::dashboard::state::{DashboardStateInput, build_dashboard_state};
23use crate::id::types::{ChildId, SupervisorPath};
24use crate::ipc::security::peer_identity::PeerIdentity;
25use crate::ipc::security::{CheckOutcome, IpcSecurityPipeline};
26use crate::journal::ring::EventJournal;
27use crate::spec::supervisor::SupervisorSpec;
28use crate::state::supervisor::SupervisorState;
29use std::os::unix::fs::{FileTypeExt, PermissionsExt};
30use std::os::unix::net::UnixStream as StdUnixStream;
31use std::sync::atomic::{AtomicU64, Ordering};
32use std::sync::{Arc, Mutex};
33use tokio::net::UnixListener;
34
35/// Target-side dashboard IPC service.
36pub struct DashboardIpcService {
37    /// Validated IPC configuration.
38    config: ValidatedDashboardIpcConfig,
39    /// Supervisor declaration used for topology payloads.
40    spec: SupervisorSpec,
41    /// Current supervisor state payload.
42    state: SupervisorState,
43    /// Recent event journal.
44    journal: EventJournal,
45    /// Optional runtime control handle.
46    handle: Option<SupervisorHandle>,
47    /// State serial number, incremented only when runtime state actually
48    /// changes (child exit, restart, control command completion, etc.).
49    /// Pure read operations (e.g. CurrentState polling) do NOT bump this
50    /// counter so clients can correctly detect genuine state transitions.
51    state_generation: AtomicU64,
52    /// Optional IPC security pipeline (C1-C9).
53    security_pipeline: Option<Arc<Mutex<IpcSecurityPipeline>>>,
54}
55
56impl DashboardIpcService {
57    /// Creates a dashboard IPC service.
58    ///
59    /// # Arguments
60    ///
61    /// - `config`: Validated target-side IPC configuration.
62    /// - `spec`: Supervisor declaration used for topology state.
63    /// - `state`: Current supervisor state.
64    /// - `journal`: Recent event journal.
65    ///
66    /// # Returns
67    ///
68    /// Returns a [`DashboardIpcService`] without a control handle.
69    pub fn new(
70        config: ValidatedDashboardIpcConfig,
71        spec: SupervisorSpec,
72        state: SupervisorState,
73        journal: EventJournal,
74    ) -> Self {
75        Self {
76            config,
77            spec,
78            state,
79            journal,
80            handle: None,
81            state_generation: AtomicU64::new(1),
82            security_pipeline: None,
83        }
84    }
85
86    /// Adds a runtime control handle to the service.
87    ///
88    /// # Arguments
89    ///
90    /// - `handle`: Runtime supervisor handle used for control commands.
91    ///
92    /// # Returns
93    ///
94    /// Returns the updated service.
95    pub fn with_handle(mut self, handle: SupervisorHandle) -> Self {
96        self.handle = Some(handle);
97        self
98    }
99
100    /// Adds an IPC security pipeline to the service.
101    ///
102    /// # Arguments
103    ///
104    /// - `pipeline`: Configured IPC security pipeline (C1-C9).
105    ///
106    /// # Returns
107    ///
108    /// Returns the updated service.
109    pub fn with_security_pipeline(mut self, pipeline: IpcSecurityPipeline) -> Self {
110        self.security_pipeline = Some(Arc::new(Mutex::new(pipeline)));
111        self
112    }
113
114    /// Returns the effective maximum frame size in bytes for the frame
115    /// reader.
116    ///
117    /// When a security pipeline is configured with C5 request size limit
118    /// enabled, returns the configured `max_bytes`. Otherwise falls back
119    /// to `DEFAULT_MAX_FRAME_BYTES` (1 MiB).
120    ///
121    /// This ensures the frame reader rejects oversized frames **before**
122    /// JSON deserialization, fulfilling C5's contract.
123    pub fn max_frame_bytes(&self) -> usize {
124        self.security_pipeline
125            .as_ref()
126            .and_then(|p| p.lock().ok())
127            .and_then(|guard| guard.request_size_limit_bytes())
128            .unwrap_or(DEFAULT_MAX_FRAME_BYTES)
129    }
130
131    /// Returns the target registration payload.
132    ///
133    /// # Arguments
134    ///
135    /// This function has no arguments.
136    ///
137    /// # Returns
138    ///
139    /// Returns the registration payload or a validation error.
140    pub fn registration_payload(&self) -> Result<TargetProcessRegistration, DashboardError> {
141        build_registration_payload(&self.config)
142    }
143
144    /// Handles one parsed IPC request with connection context.
145    ///
146    /// Runs IPC security checks before dispatching when a
147    /// security pipeline is configured.
148    ///
149    /// **Control point ordering:**
150    ///
151    /// 1. C6 (rate limit) → C5 (size limit) → C2 (peer credentials) → C3
152    ///    (authorization) — via `check`.
153    /// 2. **C8 (idempotency) checked before C4 (replay)** — if a cached
154    ///    response exists for the request_id, it is returned immediately
155    ///    without recording in the replay window. This prevents C4 from
156    ///    rejecting a legitimate retry that carries the same request_id.
157    /// 3. C4 (replay protection) — records the request_id only after
158    ///    confirming C8 had no cached result.
159    /// 4. Dispatch → C8 cache result → C7 (audit, post-dispatch).
160    ///
161    /// High-risk commands fail closed on audit write failure: when the
162    /// audit backend is unwritable and the failure strategy is
163    /// `fail_closed`, a denial response is returned instead of the normal
164    /// dispatch result.
165    ///
166    /// # Arguments
167    ///
168    /// - `request`: Parsed IPC request.
169    /// - `peer`: Real peer identity extracted from the connected socket.
170    /// - `connection_id`: Unique identifier for this connection.
171    /// - `raw_body_len`: Byte length of the raw request body.
172    ///
173    /// # Returns
174    ///
175    /// Returns a response that preserves the request identifier.
176    pub async fn handle_request(
177        &self,
178        request: IpcRequest,
179        peer: &PeerIdentity,
180        connection_id: &str,
181        raw_body_len: usize,
182    ) -> IpcResponse {
183        let method = request.method.clone();
184        let request_id = request.request_id.clone();
185        let is_high_risk = is_high_risk_command(&method, self);
186
187        if let Some(ref pipeline) = self.security_pipeline {
188            // Recover from a poisoned mutex instead of letting a single
189            // panic in a prior request deny service to all subsequent ones.
190            let mut guard = match pipeline.lock() {
191                Ok(guard) => guard,
192                Err(poisoned) => {
193                    tracing::error!(
194                        target: "rust_supervisor::ipc::security",
195                        %method,
196                        "pipeline mutex poisoned, recovering"
197                    );
198                    poisoned.into_inner()
199                }
200            };
201
202            // Step 1: C6 → C5 → C2 → C3 (pre-dispatch checks, no C4/C8)
203            match guard.check(&method, raw_body_len, peer, connection_id) {
204                CheckOutcome::Denied(err) => {
205                    let err_code = err.code.clone();
206                    // C7: audit denial
207                    let _ = self.audit_or_fail(
208                        &mut guard,
209                        &method,
210                        peer,
211                        false,
212                        Some(&err),
213                        &err_code,
214                        is_high_risk,
215                        &request_id,
216                    );
217                    return IpcResponse::error(request.request_id.clone(), err);
218                }
219                CheckOutcome::Passed => {}
220            }
221
222            // Step 2: C8 idempotency check BEFORE C4 replay.
223            // If a cached response exists, return it immediately without
224            // recording the request_id in the replay window.
225            if let Some(cached_json) = guard.check_idempotency(&request_id) {
226                let method = method.clone();
227                let peer_clone = peer.clone();
228                drop(guard);
229                // C7: audit cache hit
230                if let Some(ref pipeline) = self.security_pipeline {
231                    let mut guard = match pipeline.lock() {
232                        Ok(guard) => guard,
233                        Err(poisoned) => {
234                            tracing::error!(
235                                target: "rust_supervisor::ipc::security",
236                                %method,
237                                "pipeline mutex poisoned during C8 audit"
238                            );
239                            poisoned.into_inner()
240                        }
241                    };
242                    let _ = self.audit_or_fail(
243                        &mut guard,
244                        &method,
245                        &peer_clone,
246                        true,
247                        None,
248                        "c8_idempotency_cache_hit",
249                        is_high_risk,
250                        &request_id,
251                    );
252                }
253                // Deserialize cached JSON into IpcResponse
254                return serde_json::from_str(&cached_json).unwrap_or_else(|_| {
255                    IpcResponse::error(
256                        request_id,
257                        DashboardError::new(
258                            "idempotency_cache_corrupted",
259                            "c8_idempotency",
260                            Some(self.config.target_id.clone()),
261                            "cached response failed to deserialize".to_owned(),
262                            false,
263                        ),
264                    )
265                });
266            }
267
268            // Step 3: C4 replay protection — record request_id only after
269            // confirming no cached response exists.
270            if let Err(err) = guard.check_replay_and_record(&request_id) {
271                let err_code = err.code.clone();
272                // C7: audit replay denial
273                let _ = self.audit_or_fail(
274                    &mut guard,
275                    &method,
276                    peer,
277                    false,
278                    Some(&err),
279                    &err_code,
280                    is_high_risk,
281                    &request_id,
282                );
283                return IpcResponse::error(request.request_id.clone(), err);
284            }
285            drop(guard);
286        }
287
288        // ---- dispatch ----
289        let dispatch_result = self.dispatch(&request).await;
290        let mut response = match &dispatch_result {
291            Ok(result) => IpcResponse::ok(request.request_id.clone(), result.clone()),
292            Err(error) => IpcResponse::error(request.request_id.clone(), error.clone()),
293        };
294
295        // ---- post-dispatch: cache + audit (fail-closed for high-risk) ----
296        if let Some(ref pipeline) = self.security_pipeline {
297            let mut guard = match pipeline.lock() {
298                Ok(guard) => guard,
299                Err(poisoned) => {
300                    tracing::error!(
301                        target: "rust_supervisor::ipc::security",
302                        %method,
303                        "pipeline mutex poisoned during post-dispatch"
304                    );
305                    poisoned.into_inner()
306                }
307            };
308
309            // C8: cache dispatch result
310            if let Ok(response_json) = serde_json::to_string(&response) {
311                guard.cache_result(&request_id, &response_json);
312            }
313
314            // C7: audit dispatch outcome
315            let (allowed, denial_error, denial_code): (bool, Option<&DashboardError>, &str) =
316                match &dispatch_result {
317                    Ok(_) => (true, None, "dispatch_ok"),
318                    Err(err) => (false, Some(err), err.code.as_str()),
319                };
320            // When audit write fails for a high-risk command with fail_closed
321            // strategy, override the normal response with a denial.
322            if let Err(audit_err) = self.audit_or_fail(
323                &mut guard,
324                &method,
325                peer,
326                allowed,
327                denial_error,
328                denial_code,
329                is_high_risk,
330                &request_id,
331            ) && is_high_risk
332            {
333                tracing::error!(
334                    target: "rust_supervisor::ipc::security::audit",
335                    %method,
336                    %request_id,
337                    ?audit_err,
338                    "HIGH-RISK command denied because audit write failed (fail-closed)"
339                );
340                response = IpcResponse::error(request.request_id.clone(), audit_err);
341            }
342        }
343
344        response
345    }
346
347    /// Writes an audit record and returns the result.
348    ///
349    /// For high-risk commands with `fail_closed` strategy, audit failure
350    /// returns `Err(DashboardError)` so the caller can return a denial
351    /// response. The `write_audit` method on the pipeline already implements
352    /// the strategy dispatch; this method just logs and propagates.
353    #[allow(clippy::too_many_arguments)]
354    fn audit_or_fail(
355        &self,
356        guard: &mut std::sync::MutexGuard<'_, IpcSecurityPipeline>,
357        method: &str,
358        peer: &PeerIdentity,
359        allowed: bool,
360        denial_error: Option<&DashboardError>,
361        denial_code: &str,
362        is_high_risk: bool,
363        request_id: &str,
364    ) -> Result<(), DashboardError> {
365        guard
366            .write_audit(
367                method,
368                peer,
369                allowed,
370                denial_error,
371                denial_code,
372                is_high_risk,
373            )
374            .map_err(|err| {
375                tracing::error!(
376                    target: "rust_supervisor::ipc::security::audit",
377                    %method,
378                    %request_id,
379                    high_risk = is_high_risk,
380                    ?err,
381                    "audit write failed"
382                );
383                err
384            })
385    }
386
387    /// Dispatches one request by method.
388    ///
389    /// # Arguments
390    ///
391    /// - `request`: Parsed IPC request.
392    ///
393    /// # Returns
394    ///
395    /// Returns a typed IPC result.
396    async fn dispatch(&self, request: &IpcRequest) -> Result<IpcResult, DashboardError> {
397        let method = IpcMethod::parse(&request.method)?;
398        match method {
399            IpcMethod::Hello => Ok(IpcResult::Hello {
400                protocol_version: DASHBOARD_IPC_PROTOCOL_VERSION.to_owned(),
401                registration: self.registration_payload()?,
402            }),
403            IpcMethod::CurrentState => {
404                let state = self.current_dashboard_state().await?;
405                Ok(IpcResult::State {
406                    target_id: state.target.target_id.clone(),
407                    state: Box::new(state),
408                })
409            }
410            IpcMethod::EventsSubscribe => {
411                require_session_trigger(request, &self.config.target_id, self)?;
412                Ok(IpcResult::Subscription {
413                    target_id: self.config.target_id.clone(),
414                    subscription: "events".to_owned(),
415                })
416            }
417            IpcMethod::LogsTail => {
418                require_session_trigger(request, &self.config.target_id, self)?;
419                Ok(IpcResult::Subscription {
420                    target_id: self.config.target_id.clone(),
421                    subscription: "logs".to_owned(),
422                })
423            }
424            IpcMethod::CommandRestartChild
425            | IpcMethod::CommandPauseChild
426            | IpcMethod::CommandResumeChild
427            | IpcMethod::CommandQuarantineChild
428            | IpcMethod::CommandRemoveChild
429            | IpcMethod::CommandAddChild
430            | IpcMethod::CommandShutdownTree => self.command_result(request).await,
431        }
432    }
433
434    /// Builds the current dashboard state.
435    ///
436    /// The `state_generation` in the returned state reflects the current
437    /// serial number of the runtime — it is incremented only when runtime
438    /// state actually transitions (child exits, restarts, control commands).
439    /// Pure read operations do not bump this counter, so dashboard clients
440    /// can reliably detect genuine state changes for cache invalidation.
441    ///
442    /// # Arguments
443    ///
444    /// This function has no arguments.
445    ///
446    /// # Returns
447    ///
448    /// Returns the current [`DashboardState`].
449    pub async fn current_dashboard_state(&self) -> Result<DashboardState, DashboardError> {
450        // Read the current generation without incrementing — this is a
451        // pure read operation and should not create the illusion of change.
452        let generation = self.state_generation.load(Ordering::Relaxed);
453        let registration = self.registration_payload().ok();
454        let mut state = build_dashboard_state(
455            DashboardStateInput {
456                target_id: self.config.target_id.clone(),
457                display_name: registration
458                    .as_ref()
459                    .map(|registration| registration.display_name.clone())
460                    .unwrap_or_else(|| self.config.target_id.clone()),
461                state_generation: generation,
462                recent_limit: 128,
463            },
464            &self.spec,
465            &self.state,
466            &self.journal,
467        );
468        if let Some(handle) = self.handle.as_ref() {
469            let result = handle.current_state().await.map_err(|error| {
470                DashboardError::new(
471                    "current_state_failed",
472                    "state",
473                    Some(self.config.target_id.clone()),
474                    error.to_string(),
475                    true,
476                )
477            })?;
478            if let CommandResult::CurrentState {
479                state: runtime_state,
480            } = result
481            {
482                let dashboard_state = DashboardCurrentState::from_current_state(&runtime_state);
483                // Dashboard model attaches generation fence phase and pending restart via `DashboardCurrentState`.
484                state.runtime_state = runtime_state
485                    .child_runtime_records
486                    .iter()
487                    .map(|record| {
488                        runtime_state_from_child_runtime_record(
489                            record,
490                            runtime_state.shutdown_completed,
491                        )
492                    })
493                    .collect();
494                state.child_runtime_records = dashboard_state.child_runtime_records;
495            }
496        }
497        Ok(state)
498    }
499
500    /// Executes a control command request.
501    ///
502    /// # Arguments
503    ///
504    /// - `request`: IPC request carrying command parameters.
505    ///
506    /// # Returns
507    ///
508    /// Returns a typed command result IPC payload.
509    async fn command_result(&self, request: &IpcRequest) -> Result<IpcResult, DashboardError> {
510        let command = decode_command_params(request)?;
511        validate_command(&command)?;
512        if command.target_id != self.config.target_id {
513            return Err(DashboardError::validation(
514                "command_validate",
515                Some(self.config.target_id.clone()),
516                "command target_id must match target process",
517            ));
518        }
519        let result = if let Some(handle) = self.handle.as_ref() {
520            execute_command(handle, &command).await
521        } else {
522            Err(DashboardError::target_unavailable(
523                "command_dispatch",
524                command.target_id.clone(),
525                "runtime control handle is not attached",
526            ))
527        };
528        // Bump state generation on every completed command — the runtime
529        // state may have changed as a result of the command execution.
530        // This ensures dashboard clients can reliably detect real changes
531        // while pure read operations (CurrentState) do not bump the counter.
532        let _ = self.state_generation.fetch_add(1, Ordering::Relaxed);
533        let result = match result {
534            Ok(result) => {
535                let state_delta = dashboard_command_result_value(&result).map_err(|error| {
536                    DashboardError::new(
537                        "command_result_model_failed",
538                        "command_dispatch",
539                        Some(command.target_id.clone()),
540                        format!("failed to map command result: {error}"),
541                        false,
542                    )
543                })?;
544                ControlCommandResult {
545                    command_id: command.command_id.clone(),
546                    target_id: command.target_id.clone(),
547                    accepted: true,
548                    status: "completed".to_owned(),
549                    error: None,
550                    state_delta: Some(state_delta),
551                    completed_at_unix_nanos: Some(unix_nanos_now()),
552                }
553            }
554            Err(error) => ControlCommandResult {
555                command_id: command.command_id.clone(),
556                target_id: command.target_id.clone(),
557                accepted: false,
558                status: "failed".to_owned(),
559                error: Some(error),
560                state_delta: None,
561                completed_at_unix_nanos: Some(unix_nanos_now()),
562            },
563        };
564        Ok(IpcResult::CommandResult {
565            target_id: command.target_id,
566            result,
567        })
568    }
569}
570
571/// Binds a target-side Unix domain socket listener.
572///
573/// # Arguments
574///
575/// - `config`: Validated IPC configuration.
576///
577/// # Returns
578///
579/// Returns a bound [`UnixListener`].
580pub fn bind_dashboard_listener(
581    config: &ValidatedDashboardIpcConfig,
582) -> Result<UnixListener, DashboardError> {
583    prepare_socket_path(config)?;
584    // Ensure the parent directory exists before binding.
585    if let Some(parent) = config.path.parent() {
586        std::fs::create_dir_all(parent).map_err(|error| {
587            DashboardError::new(
588                "ipc_parent_dir_creation_failed",
589                "ipc_bind",
590                Some(config.target_id.clone()),
591                format!("failed to create IPC parent directory: {error}"),
592                false,
593            )
594        })?;
595    }
596    let listener = UnixListener::bind(&config.path).map_err(|error| {
597        DashboardError::new(
598            "ipc_bind_failed",
599            "ipc_bind",
600            Some(config.target_id.clone()),
601            format!("failed to bind target IPC socket: {error}"),
602            true,
603        )
604    })?;
605
606    // ---- enforce socket permissions immediately after bind ----
607    let mode = parse_permissions_string(&config.permissions, &config.target_id)?;
608    set_socket_permissions(&config.path, mode, &config.target_id)?;
609
610    Ok(listener)
611}
612
613/// Parses a Unix permission octal string (e.g. "0600", "0777") into a
614/// `u32` mode bits, rejecting malformed or overly permissive values.
615///
616/// # Arguments
617///
618/// - `perm_str`: Permission string, typically "0600".
619/// - `target_id`: Target identifier for error messages.
620///
621/// # Returns
622///
623/// Returns the mode bits on success.
624fn parse_permissions_string(perm_str: &str, target_id: &str) -> Result<u32, DashboardError> {
625    // Must be 4 octal digits, e.g. "0600".
626    if perm_str.len() != 4 || !perm_str.bytes().all(|b| b.is_ascii_digit()) {
627        return Err(DashboardError::validation(
628            "ipc_bind",
629            Some(target_id.to_owned()),
630            format!("dashboard.permissions must be a 4-digit octal string, got \"{perm_str}\""),
631        ));
632    }
633    let mode = u32::from_str_radix(perm_str, 8).map_err(|_| {
634        DashboardError::validation(
635            "ipc_bind",
636            Some(target_id.to_owned()),
637            format!("dashboard.permissions \"{perm_str}\" is not valid octal"),
638        )
639    })?;
640    // Reject world-writable sockets (others write bit set).
641    if mode & 0o002 != 0 {
642        return Err(DashboardError::validation(
643            "ipc_bind",
644            Some(target_id.to_owned()),
645            format!(
646                "dashboard.permissions \"{perm_str}\" grants world-write access, \
647                 which is not allowed for Unix domain sockets"
648            ),
649        ));
650    }
651    Ok(mode)
652}
653
654/// Sets Unix socket file permissions using `std::fs::set_permissions`.
655///
656/// # Arguments
657///
658/// - `path`: Socket file path.
659/// - `mode`: Permission mode bits (e.g. 0o600).
660/// - `target_id`: Target identifier for error messages.
661///
662/// # Returns
663///
664/// Returns `Ok(())` when permissions were applied.
665fn set_socket_permissions(
666    path: &std::path::Path,
667    mode: u32,
668    target_id: &str,
669) -> Result<(), DashboardError> {
670    let permissions = std::fs::Permissions::from_mode(mode);
671    std::fs::set_permissions(path, permissions).map_err(|error| {
672        DashboardError::new(
673            "ipc_set_permissions_failed",
674            "ipc_bind",
675            Some(target_id.to_owned()),
676            format!("failed to set socket permissions to {mode:#o}: {error}"),
677            true,
678        )
679    })
680}
681
682/// Prepares the configured socket path before binding.
683///
684/// # Arguments
685///
686/// - `config`: Validated IPC configuration.
687///
688/// # Returns
689///
690/// Returns `Ok(())` when binding may continue.
691fn prepare_socket_path(config: &ValidatedDashboardIpcConfig) -> Result<(), DashboardError> {
692    let metadata = match std::fs::symlink_metadata(&config.path) {
693        Ok(metadata) => metadata,
694        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
695        Err(error) => {
696            return Err(DashboardError::new(
697                "ipc_path_metadata_failed",
698                "ipc_bind",
699                Some(config.target_id.clone()),
700                format!("failed to inspect IPC path: {error}"),
701                false,
702            ));
703        }
704    };
705    match config.bind_mode {
706        crate::config::configurable::DashboardIpcBindMode::CreateNew => {
707            Err(DashboardError::validation(
708                "ipc_bind",
709                Some(config.target_id.clone()),
710                "IPC path already exists and bind_mode is create_new",
711            ))
712        }
713        crate::config::configurable::DashboardIpcBindMode::ReplaceStale => {
714            if metadata.file_type().is_symlink() {
715                return Err(DashboardError::validation(
716                    "ipc_bind",
717                    Some(config.target_id.clone()),
718                    "IPC path must not be a symlink",
719                ));
720            }
721            if !metadata.file_type().is_socket() {
722                return Err(DashboardError::validation(
723                    "ipc_bind",
724                    Some(config.target_id.clone()),
725                    "IPC path must be a Unix socket before stale replacement",
726                ));
727            }
728            if StdUnixStream::connect(&config.path).is_ok() {
729                return Err(DashboardError::validation(
730                    "ipc_bind",
731                    Some(config.target_id.clone()),
732                    "IPC path is served by a live process",
733                ));
734            }
735            // C1: socket owner check before removal
736            crate::ipc::security::peer_identity::prepare_socket_path_for_bind(&config.path)?;
737            std::fs::remove_file(&config.path).map_err(|error| {
738                DashboardError::new(
739                    "ipc_stale_remove_failed",
740                    "ipc_bind",
741                    Some(config.target_id.clone()),
742                    format!("failed to remove stale IPC path: {error}"),
743                    true,
744                )
745            })
746        }
747    }
748}
749
750/// Validates that subscription was triggered by an established session.
751///
752/// The relay must have already established a real dashboard session (peer
753/// identity verified through C2/C3 security checks) before forwarding
754/// subscription requests. This function verifies that the request came
755/// through a security pipeline that has performed peer authentication.
756///
757/// If no security pipeline is configured, the subscription is denied
758/// because there is no way to verify the caller's identity — this
759/// prevents unauthorized event/log access when the Unix socket is
760/// exposed to untrusted processes.
761///
762/// # Arguments
763///
764/// - `request`: Subscription request parameters. The `session_established`
765///   flag is a relay assertion that must be backed by a live security
766///   pipeline; it is not trusted on its own.
767/// - `target_id`: Target process identifier.
768/// - `service`: Dashboard IPC service that owns the security pipeline.
769///
770/// # Returns
771///
772/// Returns `Ok(())` when the relay provided the session trigger flag and
773/// a security pipeline is active.
774fn require_session_trigger(
775    request: &IpcRequest,
776    target_id: &str,
777    service: &DashboardIpcService,
778) -> Result<(), DashboardError> {
779    // A security pipeline must be present — otherwise there is no peer
780    // identity verification and the `session_established` flag is just an
781    // untrusted JSON parameter.
782    if service.security_pipeline.is_none() {
783        return Err(DashboardError::new(
784            "session_required",
785            "subscription",
786            Some(target_id.to_owned()),
787            "event and log subscription require a security pipeline; \
788             without one the session_established flag cannot be verified",
789            false,
790        ));
791    }
792
793    let established = request
794        .params
795        .get("session_established")
796        .and_then(serde_json::Value::as_bool)
797        .unwrap_or(false);
798    if established {
799        Ok(())
800    } else {
801        Err(DashboardError::new(
802            "session_required",
803            "subscription",
804            Some(target_id.to_owned()),
805            "event and log subscription must be triggered by an established dashboard session",
806            false,
807        ))
808    }
809}
810
811/// Validates dashboard control command rules.
812///
813/// # Arguments
814///
815/// - `command`: Command request supplied by relay.
816///
817/// # Returns
818///
819/// Returns `Ok(())` when command input is acceptable.
820pub fn validate_command(command: &ControlCommandRequest) -> Result<(), DashboardError> {
821    if command.reason.trim().is_empty() {
822        return Err(DashboardError::validation(
823            "command_validate",
824            Some(command.target_id.clone()),
825            "command reason must not be empty",
826        ));
827    }
828    if command.requested_by.trim().is_empty() {
829        return Err(DashboardError::validation(
830            "command_validate",
831            Some(command.target_id.clone()),
832            "requested_by must be derived by relay",
833        ));
834    }
835    // command_id must be a valid UUID — never silently replace it.
836    // Silently generating a new UUID would break audit trails and
837    // idempotency cache lookups.
838    if uuid::Uuid::parse_str(&command.command_id).is_err() {
839        return Err(DashboardError::validation(
840            "command_validate",
841            Some(command.target_id.clone()),
842            format!(
843                "command_id is not a valid UUID: {id}",
844                id = command.command_id
845            ),
846        ));
847    }
848    // AddChild requires a non-empty manifest.
849    if matches!(command.command, ControlCommandKind::AddChild) {
850        let has_manifest = command
851            .target
852            .child_manifest
853            .as_ref()
854            .is_some_and(|m| !m.trim().is_empty());
855        if !has_manifest {
856            return Err(DashboardError::validation(
857                "command_validate",
858                Some(command.target_id.clone()),
859                "add_child command requires a non-empty child_manifest",
860            ));
861        }
862    }
863    // Child-targeting commands require a non-empty child_path.
864    if matches!(
865        command.command,
866        ControlCommandKind::RestartChild
867            | ControlCommandKind::PauseChild
868            | ControlCommandKind::ResumeChild
869            | ControlCommandKind::QuarantineChild
870            | ControlCommandKind::RemoveChild
871    ) && command
872        .target
873        .child_path
874        .as_ref()
875        .is_none_or(|p| p.trim().is_empty())
876    {
877        return Err(DashboardError::validation(
878            "command_validate",
879            Some(command.target_id.clone()),
880            format!(
881                "{:?} command requires a non-empty child_path",
882                command.command
883            ),
884        ));
885    }
886    // requested_at_unix_nanos should not be 0 or obviously in the future.
887    // A value of 0 means the field was not set (programming error).
888    if command.requested_at_unix_nanos == 0 {
889        return Err(DashboardError::validation(
890            "command_validate",
891            Some(command.target_id.clone()),
892            "requested_at_unix_nanos must not be 0",
893        ));
894    }
895    if matches!(
896        command.command,
897        ControlCommandKind::ShutdownTree
898            | ControlCommandKind::RemoveChild
899            | ControlCommandKind::AddChild
900    ) && !command.confirmed
901    {
902        return Err(DashboardError::validation(
903            "command_validate",
904            Some(command.target_id.clone()),
905            "dangerous command requires confirmation",
906        ));
907    }
908    Ok(())
909}
910
911/// Default command timeout in seconds, matching the registration payload's
912/// declared timeout_seconds = 30. This ensures the IPC layer enforces the
913/// same deadline that relay and UI expect.
914const COMMAND_TIMEOUT_SECS: u64 = 30;
915
916/// Executes a validated command through a runtime handle,
917/// preserving the relay-supplied command_id for end-to-end tracing.
918///
919/// # Arguments
920///
921/// - `handle`: Runtime control handle.
922/// - `command`: Validated command request.
923///
924/// # Returns
925///
926/// Returns a runtime command result or dashboard error.
927async fn execute_command(
928    handle: &SupervisorHandle,
929    command: &ControlCommandRequest,
930) -> Result<CommandResult, DashboardError> {
931    // Enforce the command timeout declared in the registration payload.
932    // Without this, a hung control loop (e.g. shutdown blocked on an
933    // unyielding child) would leave the relay and UI waiting indefinitely.
934    tokio::time::timeout(
935        std::time::Duration::from_secs(COMMAND_TIMEOUT_SECS),
936        execute_command_inner(handle, command),
937    )
938    .await
939    .map_err(|_elapsed| {
940        DashboardError::new(
941            "command_timeout",
942            "command_dispatch",
943            Some(command.target_id.clone()),
944            format!(
945                "command {:?} timed out after {}s",
946                command.command, COMMAND_TIMEOUT_SECS,
947            ),
948            true,
949        )
950    })?
951}
952
953/// Inner body of [`execute_command`], without the timeout wrapper.
954async fn execute_command_inner(
955    handle: &SupervisorHandle,
956    command: &ControlCommandRequest,
957) -> Result<CommandResult, DashboardError> {
958    // Build a CommandMeta with the relay-supplied command_id so that
959    // audit events and runtime state carry the same identifier that
960    // the relay and UI see. The command_id is validated as a legal
961    // UUID by validate_command() before dispatch, so unwrap is safe.
962    let command_id = command
963        .command_id
964        .parse::<uuid::Uuid>()
965        .expect("command_id already validated as legal UUID");
966    let meta = crate::control::command::CommandMeta::with_id(
967        crate::control::command::CommandId::from_uuid(command_id),
968        &command.requested_by,
969        &command.reason,
970    );
971
972    let result = match command.command {
973        ControlCommandKind::RestartChild => {
974            handle
975                .execute_with_command_id(crate::control::command::ControlCommand::RestartChild {
976                    meta: meta.clone(),
977                    child_id: child_id(command)?,
978                })
979                .await
980        }
981        ControlCommandKind::PauseChild => {
982            handle
983                .execute_with_command_id(crate::control::command::ControlCommand::PauseChild {
984                    meta: meta.clone(),
985                    child_id: child_id(command)?,
986                })
987                .await
988        }
989        ControlCommandKind::ResumeChild => {
990            handle
991                .execute_with_command_id(crate::control::command::ControlCommand::ResumeChild {
992                    meta: meta.clone(),
993                    child_id: child_id(command)?,
994                })
995                .await
996        }
997        ControlCommandKind::QuarantineChild => {
998            handle
999                .execute_with_command_id(crate::control::command::ControlCommand::QuarantineChild {
1000                    meta: meta.clone(),
1001                    child_id: child_id(command)?,
1002                })
1003                .await
1004        }
1005        ControlCommandKind::RemoveChild => {
1006            handle
1007                .execute_with_command_id(crate::control::command::ControlCommand::RemoveChild {
1008                    meta: meta.clone(),
1009                    child_id: child_id(command)?,
1010                })
1011                .await
1012        }
1013        ControlCommandKind::AddChild => {
1014            handle
1015                .execute_with_command_id(crate::control::command::ControlCommand::AddChild {
1016                    meta: meta.clone(),
1017                    target: SupervisorPath::root(),
1018                    child_manifest: command.target.child_manifest.clone().unwrap_or_default(),
1019                })
1020                .await
1021        }
1022        ControlCommandKind::ShutdownTree => {
1023            handle
1024                .execute_with_command_id(crate::control::command::ControlCommand::ShutdownTree {
1025                    meta: meta.clone(),
1026                })
1027                .await
1028        }
1029    };
1030    result.map_err(|error| {
1031        DashboardError::new(
1032            "command_failed",
1033            "command_dispatch",
1034            Some(command.target_id.clone()),
1035            error.to_string(),
1036            true,
1037        )
1038    })
1039}
1040
1041/// Extracts a child identifier from a command target.
1042///
1043/// # Arguments
1044///
1045/// - `command`: Command request with child path target.
1046///
1047/// # Returns
1048///
1049/// Returns the final child path segment as [`ChildId`].
1050fn child_id(command: &ControlCommandRequest) -> Result<ChildId, DashboardError> {
1051    let child_path = command.target.child_path.as_deref().ok_or_else(|| {
1052        DashboardError::validation(
1053            "command_validate",
1054            Some(command.target_id.clone()),
1055            "child_path is required for child command",
1056        )
1057    })?;
1058    let value = child_path
1059        .rsplit('/')
1060        .find(|segment| !segment.is_empty())
1061        .unwrap_or(child_path);
1062    Ok(ChildId::new(value))
1063}
1064
1065/// Reads current wall-clock time as Unix nanoseconds.
1066///
1067/// # Arguments
1068///
1069/// This function has no arguments.
1070///
1071/// # Returns
1072///
1073/// Returns zero when the clock is before the Unix epoch.
1074fn unix_nanos_now() -> u128 {
1075    std::time::SystemTime::now()
1076        .duration_since(std::time::UNIX_EPOCH)
1077        .unwrap_or(std::time::Duration::ZERO)
1078        .as_nanos()
1079}
1080
1081/// Returns `true` when the method is a high-risk command that must not
1082/// execute without a successful audit write (fail-closed).
1083///
1084/// When a security pipeline is configured, reads the method list from
1085/// `AuthorizationConfig.high_risk_commands`. Otherwise falls back to
1086/// a hardcoded set that includes all write/destructive commands plus
1087/// pause and resume.
1088fn is_high_risk_command(method: &str, service: &DashboardIpcService) -> bool {
1089    // Prefer configured list when security pipeline is available.
1090    if let Some(ref pipeline) = service.security_pipeline
1091        && let Ok(guard) = pipeline.lock()
1092    {
1093        let configured = guard.high_risk_methods();
1094        if !configured.is_empty() {
1095            return configured.iter().any(|m| m == method);
1096        }
1097    }
1098    // Fallback: all write/destructive commands including pause/resume.
1099    matches!(
1100        method,
1101        "command.restart_child"
1102            | "command.pause_child"
1103            | "command.resume_child"
1104            | "command.quarantine_child"
1105            | "command.remove_child"
1106            | "command.shutdown_tree"
1107            | "command.add_child"
1108    )
1109}