Skip to main content

secureops_ipc/
lib.rs

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