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}