Skip to main content

secureops_ipc/
lib.rs

1//! # secureops-ipc
2//!
3//! Unix-domain-socket JSON-RPC protocol and peer-credential authentication for
4//! the SecureOps control plane.
5//!
6//! ## Why this crate exists (PRODUCT.md A.3, A.4)
7//!
8//! The privileged daemon (`secureops-daemon`) and the unprivileged clients
9//! (`secureops-cli`, the `secureops-napi` shim) talk over a **unix domain
10//! socket**. Per PRODUCT.md A.3 ("Process & privilege model"), the daemon does
11//! **not** trust a bearer token the agent could leak - instead it authenticates
12//! the connecting process's `uid`/`pid` directly from the kernel via
13//! `SO_PEERCRED` (Linux) / `LOCAL_PEERCRED` (macOS). This module is the single
14//! shared definition of:
15//!
16//! * the request/response wire enums ([`IpcRequest`] / [`IpcResponse`]),
17//! * the peer-credential type ([`PeerCred`]) and its OS-specific reader
18//!   ([`peer_cred`]),
19//! * the server ([`serve`]) and client ([`connect`]) skeletons.
20//!
21//! Because both Ring 1 (napi) and Ring 2 (daemon) speak this protocol over the
22//! same socket, the wire format is a frozen contract (PRODUCT.md A.5): all enums
23//! derive `serde` with `rename_all = "camelCase"` / `snake_case` tags so the
24//! bytes are stable across the migration window.
25//!
26//! All transport bodies are fully implemented (peer_cred, serve, connect, request).
27
28use std::path::Path;
29
30use serde::{Deserialize, Serialize};
31
32// Re-export the frozen core contract carried across the wire so callers of this
33// crate (daemon, cli, napi) get one consistent set of types.
34pub use secureops_core::{AuditOptions, AuditReport, MonitorAlert, MonitorStatus};
35
36// ---------------------------------------------------------------------------
37// Errors
38// ---------------------------------------------------------------------------
39
40/// Errors raised while framing, transporting, or authenticating IPC messages.
41///
42/// PRODUCT.md A.3/A.4: transport + peer-credential failures are distinct from
43/// application-level failures (which travel in-band as [`IpcResponse::Err`]).
44#[derive(Debug, thiserror::Error)]
45pub enum IpcError {
46    /// Underlying socket / framing I/O failed.
47    #[error("ipc transport i/o error: {0}")]
48    Io(#[from] std::io::Error),
49
50    /// A frame could not be (de)serialized to/from JSON.
51    #[error("ipc codec error: {0}")]
52    Codec(#[from] serde_json::Error),
53
54    /// The connecting peer failed the `SO_PEERCRED`/`LOCAL_PEERCRED` check
55    /// (PRODUCT.md A.3 - uid/pid not in the allowed set).
56    #[error("ipc peer authentication denied: {0}")]
57    Unauthorized(String),
58
59    /// Peer-credential introspection is not implemented for this OS.
60    #[error("ipc peer-cred not supported on this platform")]
61    UnsupportedPlatform,
62}
63
64/// Convenience result alias for IPC transport operations.
65pub type IpcResult<T> = std::result::Result<T, IpcError>;
66
67// ---------------------------------------------------------------------------
68// Wire protocol - request enum (PRODUCT.md A.4)
69// ---------------------------------------------------------------------------
70
71/// A request sent from a client (cli / napi) to the daemon over the socket.
72///
73/// Internally tagged so the JSON stays self-describing and stable across the
74/// TS↔Rust migration window (PRODUCT.md A.5). Variant tags are `camelCase`.
75///
76/// PRODUCT.md A.4: this is the control-plane verb set shared by every Ring-1/2
77/// process. Variants map onto the daemon workflows in PRODUCT.md Part B.
78#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
79#[serde(tag = "type", rename_all = "camelCase")]
80pub enum IpcRequest {
81    /// Run a (read-only) audit and return the [`AuditReport`] (PRODUCT.md B.2).
82    ///
83    /// `AuditOptions` (core) is `Default + Copy` but does not derive serde, so
84    /// the wire form carries the three knobs flatly; the daemon rebuilds an
85    /// `AuditOptions` from them before invoking `run_audit`.
86    Audit {
87        /// Deep-scan toggle forwarded from the caller.
88        #[serde(default)]
89        deep: bool,
90        /// Auto-fix toggle forwarded from the caller.
91        #[serde(default)]
92        fix: bool,
93        /// JSON-output toggle forwarded from the caller.
94        #[serde(default)]
95        json: bool,
96    },
97
98    /// Query daemon liveness + monitor status (PRODUCT.md B.4).
99    Status,
100
101    /// Trip the kill switch / request enforcement shutdown (PRODUCT.md A.3,
102    /// B.4). The optional human-readable `reason` is recorded in the audit log.
103    Kill {
104        /// Why the kill switch was tripped (for the signed log).
105        reason: Option<String>,
106    },
107
108    /// Subscribe to the live [`MonitorAlert`] stream from the AlertBus
109    /// (PRODUCT.md B.4). The server keeps the connection open and pushes
110    /// [`IpcResponse::Alert`] frames until the client disconnects.
111    Subscribe,
112
113    /// Fetch the most recent monitor alerts (bounded by `limit`).
114    Alerts {
115        /// Maximum number of alerts to return.
116        limit: Option<u32>,
117    },
118
119    /// Ask the daemon to reload its policy bundle from disk
120    /// (PRODUCT.md B.4 hot-reload). The PDP lives in `secureops-policy`.
121    ReloadPolicy,
122
123    /// Liveness ping; the daemon answers with [`IpcResponse::Ok`].
124    Ping,
125}
126
127// ---------------------------------------------------------------------------
128// Wire protocol - response enum (PRODUCT.md A.4)
129// ---------------------------------------------------------------------------
130
131/// A response (or pushed event) sent from the daemon back to a client.
132///
133/// Application-level failures travel **in-band** as [`IpcResponse::Err`] so a
134/// failing check never tears down the transport (mirrors the audit "run never
135/// aborts" rule, PRODUCT.md B.2). Transport/auth failures use [`IpcError`].
136///
137/// `serde` internally tagged, `camelCase` - frozen wire contract (A.5).
138#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
139#[serde(tag = "type", rename_all = "camelCase")]
140pub enum IpcResponse {
141    /// Success carrying an arbitrary JSON payload (e.g. a serialized
142    /// [`AuditReport`] or [`MonitorStatus`]).
143    Ok(serde_json::Value),
144
145    /// In-band application error with a human-readable message.
146    Err(String),
147
148    /// A pushed alert frame delivered on a [`IpcRequest::Subscribe`] stream
149    /// (PRODUCT.md B.4).
150    Alert(MonitorAlert),
151}
152
153impl IpcResponse {
154    /// Build an [`IpcResponse::Ok`] from any serializable value.
155    ///
156    /// PRODUCT.md A.4 - convenience used by the daemon's request handler to wrap
157    /// typed results (reports, status) into the generic `Ok(Value)` frame.
158    pub fn ok<T: Serialize>(value: &T) -> IpcResult<Self> {
159        Ok(IpcResponse::Ok(serde_json::to_value(value)?))
160    }
161
162    /// Build an [`IpcResponse::Err`] from any displayable error.
163    pub fn err(msg: impl std::fmt::Display) -> Self {
164        IpcResponse::Err(msg.to_string())
165    }
166}
167
168// ---------------------------------------------------------------------------
169// Peer credentials (PRODUCT.md A.3)
170// ---------------------------------------------------------------------------
171
172/// Kernel-reported identity of the process on the other end of the socket.
173///
174/// PRODUCT.md A.3: the daemon authenticates the connecting process's `uid`/`pid`
175/// via `SO_PEERCRED` (Linux) / `LOCAL_PEERCRED` (macOS) rather than trusting a
176/// token the agent could leak. This is the value those calls populate.
177#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
178pub struct PeerCred {
179    /// Effective user id of the connecting process.
180    pub uid: u32,
181    /// Process id of the connecting process.
182    pub pid: i32,
183}
184
185impl PeerCred {
186    /// Authorization predicate: is this peer the expected service/owner uid?
187    ///
188    /// PRODUCT.md A.3 - the daemon runs as the dedicated `secureops` user and
189    /// accepts connections from the owning operator uid. Real policy is wired by
190    /// the daemon; this is the building block.
191    pub fn is_authorized(&self, allowed_uid: u32) -> bool {
192        self.uid == allowed_uid
193    }
194}
195
196/// Read the peer credentials of a connected unix-socket stream (PRODUCT.md A.3).
197///
198/// Uses tokio's built-in `peer_cred()` which calls `SO_PEERCRED` (Linux) or
199/// `getpeereid` (macOS) through the kernel. `pid` is `-1` on platforms where
200/// it is not available in a single call.
201#[cfg(unix)]
202pub fn peer_cred(stream: &tokio::net::UnixStream) -> std::io::Result<PeerCred> {
203    let ucred = stream.peer_cred()?;
204    Ok(PeerCred {
205        uid: ucred.uid(),
206        pid: ucred.pid().unwrap_or(-1),
207    })
208}
209
210// ---------------------------------------------------------------------------
211// Handler trait - the daemon-side request dispatcher
212// ---------------------------------------------------------------------------
213
214/// Server-side request handler implemented by `secureops-daemon`.
215///
216/// PRODUCT.md A.4/B.4 - [`serve`] accepts a connection, authenticates the peer
217/// ([`peer_cred`]), then routes each decoded [`IpcRequest`] through this trait.
218/// Keeping the handler abstract lets the daemon inject its PDP/PEP/AlertBus
219/// wiring while this crate owns only the transport.
220#[async_trait::async_trait]
221pub trait IpcHandler: Send + Sync {
222    /// Handle one request from an authenticated peer, producing one response.
223    ///
224    /// The `peer` argument carries the kernel-verified [`PeerCred`] so handlers
225    /// can apply per-uid authorization (PRODUCT.md A.3).
226    async fn handle(&self, peer: PeerCred, request: IpcRequest) -> IpcResponse;
227}
228
229// ---------------------------------------------------------------------------
230// Server skeleton (PRODUCT.md A.4, B.4)
231// ---------------------------------------------------------------------------
232
233/// Bind a `UnixListener` at `path` and serve newline-delimited JSON-RPC until
234/// the listener is dropped. Each connection is authenticated via [`peer_cred`]
235/// and dispatched through `handler` (PRODUCT.md A.3/A.4/B.4).
236#[cfg(unix)]
237pub async fn serve<H, P>(path: P, handler: H) -> IpcResult<()>
238where
239    H: IpcHandler + 'static,
240    P: AsRef<Path>,
241{
242    use std::sync::Arc;
243    use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
244    use tokio::net::UnixListener;
245
246    let listener = UnixListener::bind(path.as_ref())?;
247    let handler = Arc::new(handler);
248
249    loop {
250        let (stream, _) = listener.accept().await?;
251        let peer = peer_cred(&stream).unwrap_or(PeerCred {
252            uid: u32::MAX,
253            pid: -1,
254        });
255        let handler = handler.clone();
256        tokio::spawn(async move {
257            let (read_half, mut write_half) = stream.into_split();
258            let mut lines = BufReader::new(read_half).lines();
259            while let Ok(Some(line)) = lines.next_line().await {
260                let request: IpcRequest = match serde_json::from_str(&line) {
261                    Ok(r) => r,
262                    Err(e) => {
263                        let resp = IpcResponse::err(e);
264                        let _ = write_half
265                            .write_all(
266                                format!("{}\n", serde_json::to_string(&resp).unwrap_or_default())
267                                    .as_bytes(),
268                            )
269                            .await;
270                        continue;
271                    }
272                };
273                let response = handler.handle(peer, request).await;
274                let _ = write_half
275                    .write_all(
276                        format!("{}\n", serde_json::to_string(&response).unwrap_or_default())
277                            .as_bytes(),
278                    )
279                    .await;
280            }
281        });
282    }
283}
284
285// ---------------------------------------------------------------------------
286// Client skeleton (PRODUCT.md A.4)
287// ---------------------------------------------------------------------------
288
289/// A connected client handle to the daemon's control socket.
290///
291/// PRODUCT.md A.4 - used by `secureops-cli` and `secureops-napi` to send
292/// [`IpcRequest`]s and read [`IpcResponse`]s over the unix socket.
293#[cfg(unix)]
294pub struct IpcClient {
295    stream: tokio::net::UnixStream,
296}
297
298#[cfg(unix)]
299impl IpcClient {
300    /// Write a newline-delimited JSON [`IpcRequest`], read one [`IpcResponse`].
301    pub async fn request(&mut self, request: IpcRequest) -> IpcResult<IpcResponse> {
302        use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
303
304        let json = serde_json::to_string(&request)?;
305        let (read_half, mut write_half) = self.stream.split();
306        write_half
307            .write_all(format!("{}\n", json).as_bytes())
308            .await?;
309
310        let mut reader = BufReader::new(read_half);
311        let mut line = String::new();
312        reader.read_line(&mut line).await?;
313        let response: IpcResponse = serde_json::from_str(line.trim())?;
314        Ok(response)
315    }
316}
317
318/// Connect to the daemon control socket at `path` (PRODUCT.md A.4).
319#[cfg(unix)]
320pub async fn connect<P: AsRef<Path>>(path: P) -> IpcResult<IpcClient> {
321    use tokio::net::UnixStream;
322    let stream = UnixStream::connect(path.as_ref()).await?;
323    Ok(IpcClient { stream })
324}
325
326// ---------------------------------------------------------------------------
327// Tests - wire-contract round-trips only (no I/O, compile-time safety net)
328// ---------------------------------------------------------------------------
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333
334    #[test]
335    fn request_round_trips_through_json() {
336        let req = IpcRequest::Kill {
337            reason: Some("manual trip".to_string()),
338        };
339        let bytes = serde_json::to_vec(&req).expect("serialize");
340        let back: IpcRequest = serde_json::from_slice(&bytes).expect("deserialize");
341        assert_eq!(req, back);
342    }
343
344    #[test]
345    fn response_ok_wraps_value() {
346        let resp = IpcResponse::ok(&"pong").expect("ok wrap");
347        match resp {
348            IpcResponse::Ok(v) => assert_eq!(v, serde_json::json!("pong")),
349            other => panic!("expected Ok, got {other:?}"),
350        }
351    }
352
353    #[test]
354    fn peer_cred_authorization() {
355        let pc = PeerCred {
356            uid: 501,
357            pid: 4242,
358        };
359        assert!(pc.is_authorized(501));
360        assert!(!pc.is_authorized(0));
361    }
362}