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}