core_ipc/
client.rs

1//! IPC bus client — connects to the bus server, publishes events,
2//! and receives subscribed events over a Unix domain socket.
3//!
4//! Two connection modes:
5//! - `connect_encrypted()`: low-level constructor for tests and CLI clients.
6//! - `connect_daemon_with_keypair_retry()`: production constructor for daemons.
7//!   Spawns an I/O task that transparently handles `KeyRotationPending` —
8//!   the caller's `recv()` is never interrupted by routine key rotation.
9
10use crate::message::MessageContext;
11use core_types::{DaemonId, EventKind, SecurityLevel};
12use std::collections::HashMap;
13use std::path::{Path, PathBuf};
14use std::sync::Arc;
15use std::time::{Duration, Instant};
16use tokio::net::UnixStream;
17use tokio::sync::{Mutex, mpsc, oneshot};
18use uuid::Uuid;
19
20use crate::framing::{decode_frame, encode_frame};
21use crate::message::Message;
22use crate::transport::{extract_ucred, local_credentials};
23
24/// Retry parameters for initial IPC bus connection.
25pub struct RetryConfig {
26    /// Maximum connection attempts before giving up.
27    pub max_attempts: u32,
28    /// Base backoff duration between attempts (multiplied by attempt number).
29    pub backoff: Duration,
30}
31
32/// Metadata for transparent key rotation inside the I/O task.
33#[derive(Clone)]
34struct RotationConfig {
35    daemon_name: String,
36    daemon_id: DaemonId,
37    socket_path: PathBuf,
38    server_pub: [u8; 32],
39    capabilities: Vec<String>,
40    version: String,
41}
42
43/// The IPC bus client used by each daemon to communicate on the bus.
44pub struct BusClient {
45    daemon_id: DaemonId,
46    msg_ctx: MessageContext,
47    /// Outbound frames to send to the server.
48    outbound_tx: mpsc::Sender<Vec<u8>>,
49    /// Inbound frames received from the server (broadcast/unsolicited).
50    inbound_rx: mpsc::Receiver<Vec<u8>>,
51    /// Pending request-response waiters, keyed by `msg_id`.
52    pending: Arc<Mutex<HashMap<Uuid, oneshot::Sender<Message<EventKind>>>>>,
53    epoch: Instant,
54    /// Handle to the multiplexed I/O task (encrypted transport).
55    /// `None` for in-process test clients created via `new()`.
56    io_handle: Option<tokio::task::JoinHandle<()>>,
57}
58
59impl BusClient {
60    /// Create a new bus client with pre-wired channels (for in-process testing).
61    #[must_use]
62    pub fn new(
63        daemon_id: DaemonId,
64        outbound_tx: mpsc::Sender<Vec<u8>>,
65        inbound_rx: mpsc::Receiver<Vec<u8>>,
66    ) -> Self {
67        Self {
68            daemon_id,
69            msg_ctx: MessageContext::new(daemon_id),
70            outbound_tx,
71            inbound_rx,
72            pending: Arc::new(Mutex::new(HashMap::new())),
73            epoch: Instant::now(),
74            io_handle: None,
75        }
76    }
77
78    /// Connect to the bus server with Noise IK encrypted transport.
79    ///
80    /// Low-level constructor used by tests and `connect_with_keypair_retry`.
81    /// Does NOT handle key rotation — the I/O task is a plain byte pipe.
82    /// Production daemons should use `connect_daemon_with_keypair_retry` instead.
83    ///
84    /// # Errors
85    ///
86    /// Returns an error if connection, key read, or handshake fails.
87    pub async fn connect_encrypted(
88        daemon_id: DaemonId,
89        path: &Path,
90        server_public_key: &[u8; 32],
91        client_keypair: &snow::Keypair,
92    ) -> core_types::Result<Self> {
93        let stream = connect_with_retry(path, 3, Duration::from_millis(100)).await?;
94        let server_creds = extract_ucred(&stream)?;
95        let local_creds = local_credentials();
96
97        let (reader, writer) = stream.into_split();
98        let mut reader = tokio::io::BufReader::new(reader);
99        let mut writer = tokio::io::BufWriter::new(writer);
100
101        let transport = crate::noise::client_handshake(
102            &mut reader,
103            &mut writer,
104            server_public_key,
105            client_keypair,
106            &local_creds,
107            &server_creds,
108        )
109        .await?;
110
111        let (outbound_tx, mut outbound_rx) = mpsc::channel::<Vec<u8>>(256);
112        let (inbound_tx, inbound_rx) = mpsc::channel::<Vec<u8>>(1024);
113        let pending: Arc<Mutex<HashMap<Uuid, oneshot::Sender<Message<EventKind>>>>> =
114            Arc::new(Mutex::new(HashMap::new()));
115
116        let pending_clone = Arc::clone(&pending);
117        let io_handle = tokio::spawn(async move {
118            let mut transport = transport;
119            let mut reader = reader;
120            let mut writer = writer;
121            loop {
122                tokio::select! {
123                    result = transport.read_encrypted_frame(&mut reader) => {
124                        if let Ok(payload) = result {
125                            route_inbound(payload, &pending_clone, &inbound_tx).await;
126                        } else {
127                            tracing::info!("server disconnected (encrypted)");
128                            break;
129                        }
130                    }
131                    msg = outbound_rx.recv() => {
132                        if let Some(mut payload) = msg {
133                            let result = transport.write_encrypted_frame(&mut writer, &payload).await;
134                            zeroize::Zeroize::zeroize(&mut payload);
135                            if let Err(e) = result {
136                                tracing::debug!(error = %e, "encrypted write failed, closing client");
137                                break;
138                            }
139                        } else {
140                            tracing::debug!("outbound channel closed, I/O task exiting");
141                            break;
142                        }
143                    }
144                }
145            }
146        });
147
148        Ok(Self {
149            daemon_id,
150            msg_ctx: MessageContext::new(daemon_id),
151            outbound_tx,
152            inbound_rx,
153            pending,
154            epoch: Instant::now(),
155            io_handle: Some(io_handle),
156        })
157    }
158
159    /// Connect to the bus as a named daemon with transparent key rotation.
160    ///
161    /// Same handshake as `connect_encrypted`, but the I/O task intercepts
162    /// `KeyRotationPending` messages matching `config.daemon_name` and
163    /// performs reconnection internally. The caller's `recv()` is never
164    /// interrupted by routine rotation.
165    ///
166    /// On rotation failure (disk error, connection refused), the I/O task
167    /// logs the error and continues on the existing connection. The old key
168    /// remains valid in the registry during the grace period.
169    ///
170    /// # Errors
171    ///
172    /// Returns an error if the initial connection or handshake fails.
173    async fn connect_as_daemon(
174        config: RotationConfig,
175        client_keypair: &snow::Keypair,
176    ) -> core_types::Result<Self> {
177        let stream = connect_with_retry(&config.socket_path, 3, Duration::from_millis(100)).await?;
178        let server_creds = extract_ucred(&stream)?;
179        let local_creds = local_credentials();
180
181        let (reader, writer) = stream.into_split();
182        let mut reader = tokio::io::BufReader::new(reader);
183        let mut writer = tokio::io::BufWriter::new(writer);
184
185        let transport = crate::noise::client_handshake(
186            &mut reader,
187            &mut writer,
188            &config.server_pub,
189            client_keypair,
190            &local_creds,
191            &server_creds,
192        )
193        .await?;
194
195        let daemon_id = config.daemon_id;
196        let (outbound_tx, mut outbound_rx) = mpsc::channel::<Vec<u8>>(256);
197        let (inbound_tx, inbound_rx) = mpsc::channel::<Vec<u8>>(1024);
198        let pending: Arc<Mutex<HashMap<Uuid, oneshot::Sender<Message<EventKind>>>>> =
199            Arc::new(Mutex::new(HashMap::new()));
200
201        let pending_clone = Arc::clone(&pending);
202        let io_handle = tokio::spawn(async move {
203            let mut transport = transport;
204            let mut reader = reader;
205            let mut writer = writer;
206            loop {
207                tokio::select! {
208                    result = transport.read_encrypted_frame(&mut reader) => {
209                        if let Ok(payload) = result {
210                            if let RotationResult::NotRotation = try_handle_rotation(
211                                &payload, &config, &mut transport, &mut reader, &mut writer,
212                            ).await {
213                                route_inbound(payload, &pending_clone, &inbound_tx).await;
214                            }
215                        } else {
216                            tracing::error!("server disconnected (encrypted)");
217                            break;
218                        }
219                    }
220                    msg = outbound_rx.recv() => {
221                        if let Some(mut payload) = msg {
222                            let result = transport.write_encrypted_frame(&mut writer, &payload).await;
223                            zeroize::Zeroize::zeroize(&mut payload);
224                            if let Err(e) = result {
225                                tracing::debug!(error = %e, "encrypted write failed, closing client");
226                                break;
227                            }
228                        } else {
229                            tracing::debug!("outbound channel closed, I/O task exiting");
230                            break;
231                        }
232                    }
233                }
234            }
235        });
236
237        Ok(Self {
238            daemon_id,
239            msg_ctx: MessageContext::new(daemon_id),
240            outbound_tx,
241            inbound_rx,
242            pending,
243            epoch: Instant::now(),
244            io_handle: Some(io_handle),
245        })
246    }
247
248    /// Send a message to the bus server.
249    ///
250    /// # Errors
251    ///
252    /// Returns an error if serialization fails or the connection is closed.
253    pub async fn send(&self, msg: &Message<EventKind>) -> core_types::Result<()> {
254        let payload = encode_frame(msg)?;
255        self.outbound_tx
256            .send(payload)
257            .await
258            .map_err(|_| core_types::Error::Ipc("outbound channel closed".into()))?;
259        Ok(())
260    }
261
262    /// Publish an event to the bus.
263    ///
264    /// # Errors
265    ///
266    /// Returns an error if serialization fails or the outbound channel is closed.
267    pub async fn publish(
268        &self,
269        event: EventKind,
270        security_level: SecurityLevel,
271    ) -> core_types::Result<()> {
272        let msg = Message::new(&self.msg_ctx, event, security_level, self.epoch);
273        self.send(&msg).await
274    }
275
276    /// Send a request and wait for a correlated response.
277    ///
278    /// # Errors
279    ///
280    /// Returns an error on send failure or timeout.
281    pub async fn request(
282        &self,
283        event: EventKind,
284        security_level: SecurityLevel,
285        timeout: Duration,
286    ) -> core_types::Result<Message<EventKind>> {
287        let msg = Message::new(&self.msg_ctx, event, security_level, self.epoch);
288        let msg_id = msg.msg_id;
289
290        let (tx, rx) = oneshot::channel();
291        self.pending.lock().await.insert(msg_id, tx);
292
293        self.send(&msg).await?;
294
295        match tokio::time::timeout(timeout, rx).await {
296            Ok(Ok(response)) => Ok(response),
297            Ok(Err(_)) => {
298                self.pending.lock().await.remove(&msg_id);
299                Err(core_types::Error::Ipc("response channel dropped".into()))
300            }
301            Err(_) => {
302                self.pending.lock().await.remove(&msg_id);
303                Err(core_types::Error::Ipc(format!(
304                    "request timed out after {}ms",
305                    timeout.as_millis()
306                )))
307            }
308        }
309    }
310
311    /// Receive the next broadcast/unsolicited inbound event.
312    ///
313    /// Returns `None` if the server disconnected.
314    pub async fn recv(&mut self) -> Option<Message<EventKind>> {
315        let mut payload = self.inbound_rx.recv().await?;
316        let result = decode_frame(&payload);
317        // Zeroize raw postcard bytes — may contain serialized secret values.
318        zeroize::Zeroize::zeroize(&mut payload);
319        match result {
320            Ok(msg) => Some(msg),
321            Err(e) => {
322                tracing::warn!(error = %e, "failed to decode inbound frame");
323                None
324            }
325        }
326    }
327
328    /// Return this client's daemon ID.
329    #[must_use]
330    pub fn daemon_id(&self) -> DaemonId {
331        self.daemon_id
332    }
333
334    /// Return the client's monotonic epoch.
335    #[must_use]
336    pub fn epoch(&self) -> Instant {
337        self.epoch
338    }
339
340    /// Set the installation identity on the message context.
341    pub fn set_installation(&mut self, installation: core_types::InstallationId) {
342        self.msg_ctx.installation = Some(installation);
343    }
344
345    /// Gracefully shut down the client, flushing all pending outbound frames.
346    pub async fn shutdown(self) {
347        drop(self.outbound_tx);
348        if let Some(handle) = self.io_handle {
349            let _ = handle.await;
350        }
351    }
352
353    /// Connect to the IPC bus with keypair re-read on each attempt.
354    ///
355    /// Low-level retry constructor for CLI clients and tests.
356    /// Does NOT handle key rotation. Production daemons should use
357    /// `connect_daemon_with_keypair_retry`.
358    ///
359    /// # Errors
360    ///
361    /// Returns an error if all attempts fail (keypair read or connect).
362    pub async fn connect_with_keypair_retry(
363        daemon_name: &str,
364        daemon_id: DaemonId,
365        socket_path: &Path,
366        server_pub: &[u8; 32],
367        max_attempts: u32,
368        backoff: Duration,
369    ) -> core_types::Result<(Self, crate::noise::ZeroizingKeypair)> {
370        let mut last_err = None;
371        for attempt in 1..=max_attempts {
372            let (private_key, public_key) =
373                match crate::noise::read_daemon_keypair(daemon_name).await {
374                    Ok(kp) => kp,
375                    Err(e) => {
376                        tracing::warn!(attempt, error = %e, "keypair read failed, retrying");
377                        last_err = Some(e);
378                        if attempt < max_attempts {
379                            tokio::time::sleep(backoff * attempt).await;
380                        }
381                        continue;
382                    }
383                };
384            let client_keypair = crate::noise::ZeroizingKeypair::new(snow::Keypair {
385                private: private_key.to_vec(),
386                public: public_key.to_vec(),
387            });
388
389            match Self::connect_encrypted(
390                daemon_id,
391                socket_path,
392                server_pub,
393                client_keypair.as_inner(),
394            )
395            .await
396            {
397                Ok(client) => {
398                    return Ok((client, client_keypair));
399                }
400                Err(e) => {
401                    tracing::warn!(attempt, error = %e, "IPC connect failed, retrying");
402                    last_err = Some(e);
403                    if attempt < max_attempts {
404                        tokio::time::sleep(backoff * attempt).await;
405                    }
406                }
407            }
408        }
409        Err(last_err.unwrap_or_else(|| {
410            core_types::Error::Ipc(format!("connect failed after {max_attempts} attempts"))
411        }))
412    }
413
414    /// Connect to the IPC bus as a named daemon with transparent key rotation.
415    ///
416    /// Same retry logic as `connect_with_keypair_retry`, but the returned
417    /// client's I/O task automatically handles `KeyRotationPending` messages.
418    /// The caller never sees rotation events and `recv()` is uninterrupted.
419    ///
420    /// The initial `DaemonStarted` announcement is NOT sent by this method —
421    /// the caller publishes it after connecting.
422    ///
423    /// # Errors
424    ///
425    /// Returns an error if all connection attempts fail.
426    pub async fn connect_daemon_with_keypair_retry(
427        daemon_name: &str,
428        daemon_id: DaemonId,
429        socket_path: &Path,
430        server_pub: &[u8; 32],
431        capabilities: Vec<String>,
432        version: &str,
433        retry: RetryConfig,
434    ) -> core_types::Result<Self> {
435        let config = RotationConfig {
436            daemon_name: daemon_name.to_owned(),
437            daemon_id,
438            socket_path: socket_path.to_owned(),
439            server_pub: *server_pub,
440            capabilities,
441            version: version.to_owned(),
442        };
443
444        let mut last_err = None;
445        for attempt in 1..=retry.max_attempts {
446            let (private_key, public_key) =
447                match crate::noise::read_daemon_keypair(daemon_name).await {
448                    Ok(kp) => kp,
449                    Err(e) => {
450                        tracing::warn!(attempt, error = %e, "keypair read failed, retrying");
451                        last_err = Some(e);
452                        if attempt < retry.max_attempts {
453                            tokio::time::sleep(retry.backoff * attempt).await;
454                        }
455                        continue;
456                    }
457                };
458            let client_keypair = crate::noise::ZeroizingKeypair::new(snow::Keypair {
459                private: private_key.to_vec(),
460                public: public_key.to_vec(),
461            });
462
463            match Self::connect_as_daemon(config.clone(), client_keypair.as_inner()).await {
464                Ok(client) => return Ok(client),
465                Err(e) => {
466                    tracing::warn!(attempt, error = %e, "IPC connect failed, retrying");
467                    last_err = Some(e);
468                    if attempt < retry.max_attempts {
469                        tokio::time::sleep(retry.backoff * attempt).await;
470                    }
471                }
472            }
473        }
474        Err(last_err.unwrap_or_else(|| {
475            core_types::Error::Ipc(format!(
476                "connect failed after {} attempts",
477                retry.max_attempts
478            ))
479        }))
480    }
481}
482
483/// Result of checking whether an inbound payload is a rotation message.
484enum RotationResult {
485    /// The message was a `KeyRotationPending` for this daemon and was handled.
486    /// Transport/reader/writer have been swapped. Continue the I/O loop.
487    Handled,
488    /// The message was not a rotation event. Forward it normally.
489    NotRotation,
490}
491
492/// Check if an inbound payload is a `KeyRotationPending` for this daemon.
493/// If so, reconnect with the new keypair and swap the transport in place.
494/// On failure, log the error and return `NotRotation` so the original
495/// payload is forwarded to the caller — the old connection remains valid.
496#[allow(clippy::similar_names)] // reader vs new_reader, writer vs new_writer
497async fn try_handle_rotation(
498    payload: &[u8],
499    config: &RotationConfig,
500    transport: &mut crate::noise::NoiseTransport,
501    reader: &mut tokio::io::BufReader<tokio::net::unix::OwnedReadHalf>,
502    writer: &mut tokio::io::BufWriter<tokio::net::unix::OwnedWriteHalf>,
503) -> RotationResult {
504    // Attempt decode. If it fails, this isn't a valid message for us.
505    let msg: Message<EventKind> = match decode_frame(payload) {
506        Ok(m) => m,
507        Err(_) => return RotationResult::NotRotation,
508    };
509
510    let (announced_name, announced_pubkey) = match &msg.payload {
511        EventKind::KeyRotationPending {
512            daemon_name,
513            new_pubkey,
514            ..
515        } => (daemon_name.as_str(), new_pubkey),
516        _ => return RotationResult::NotRotation,
517    };
518
519    if announced_name != config.daemon_name {
520        return RotationResult::NotRotation;
521    }
522
523    tracing::info!(
524        daemon = %config.daemon_name,
525        "key rotation: reading new keypair and reconnecting"
526    );
527
528    // Perform the reconnection. All errors are recoverable — the old
529    // connection remains valid because both keys are in the registry.
530    match perform_rotation(config, announced_pubkey, transport, reader, writer).await {
531        Ok(()) => RotationResult::Handled,
532        Err(e) => {
533            tracing::error!(
534                daemon = %config.daemon_name,
535                error = %e,
536                "key rotation failed, continuing on current connection"
537            );
538            RotationResult::NotRotation
539        }
540    }
541}
542
543/// Execute the key rotation: read keypair, connect, handshake, announce,
544/// swap transport/reader/writer in place.
545async fn perform_rotation(
546    config: &RotationConfig,
547    announced_pubkey: &[u8; 32],
548    transport: &mut crate::noise::NoiseTransport,
549    reader: &mut tokio::io::BufReader<tokio::net::unix::OwnedReadHalf>,
550    writer: &mut tokio::io::BufWriter<tokio::net::unix::OwnedWriteHalf>,
551) -> core_types::Result<()> {
552    let (new_private, new_public) = crate::noise::read_daemon_keypair(&config.daemon_name).await?;
553
554    if new_public != *announced_pubkey {
555        return Err(core_types::Error::Ipc(
556            "rotated pubkey mismatch: disk vs announced — possible tampering".into(),
557        ));
558    }
559
560    let kp = crate::noise::ZeroizingKeypair::new(snow::Keypair {
561        private: new_private.to_vec(),
562        public: new_public.to_vec(),
563    });
564
565    let new_stream = connect_with_retry(&config.socket_path, 3, Duration::from_millis(100)).await?;
566    let server_creds = extract_ucred(&new_stream)?;
567    let local_creds = local_credentials();
568
569    let (new_read_half, new_write_half) = new_stream.into_split();
570    let mut new_reader = tokio::io::BufReader::new(new_read_half);
571    let mut new_writer = tokio::io::BufWriter::new(new_write_half);
572
573    let mut new_transport = crate::noise::client_handshake(
574        &mut new_reader,
575        &mut new_writer,
576        &config.server_pub,
577        kp.as_inner(),
578        &local_creds,
579        &server_creds,
580    )
581    .await?;
582    // kp dropped here — ZeroizingKeypair::drop() zeroizes private key.
583
584    // Write DaemonStarted directly on the new connection before swapping.
585    // Uses the same DaemonId to preserve the rotation cascade invariant:
586    // DaemonTracker only triggers revocation when old_id != new_id.
587    let msg_ctx = MessageContext::new(config.daemon_id);
588    let announce = Message::new(
589        &msg_ctx,
590        EventKind::DaemonStarted {
591            daemon_id: config.daemon_id,
592            version: config.version.clone(),
593            capabilities: config.capabilities.clone(),
594        },
595        SecurityLevel::Internal,
596        Instant::now(),
597    );
598    let mut announce_bytes = encode_frame(&announce)?;
599    new_transport
600        .write_encrypted_frame(&mut new_writer, &announce_bytes)
601        .await?;
602    zeroize::Zeroize::zeroize(&mut announce_bytes);
603
604    // Swap transport, reader, writer in place. Old socket fds are dropped,
605    // causing the server to see a clean disconnect on the old connection.
606    *transport = new_transport;
607    *reader = new_reader;
608    *writer = new_writer;
609
610    tracing::info!(
611        daemon = %config.daemon_name,
612        "key rotation: transport swapped to new connection"
613    );
614
615    Ok(())
616}
617
618/// Route an inbound payload to pending waiters or the broadcast channel.
619async fn route_inbound(
620    payload: Vec<u8>,
621    pending: &Mutex<HashMap<Uuid, oneshot::Sender<Message<EventKind>>>>,
622    inbound_tx: &mpsc::Sender<Vec<u8>>,
623) {
624    match decode_frame::<Message<EventKind>>(&payload) {
625        Ok(msg) => {
626            if let Some(corr_id) = msg.correlation_id {
627                let waiter = pending.lock().await.remove(&corr_id);
628                if let Some(tx) = waiter {
629                    let _ = tx.send(msg);
630                    return;
631                }
632            }
633            if inbound_tx.try_send(payload).is_err() {
634                tracing::warn!("inbound channel full, frame dropped");
635            }
636        }
637        Err(e) => {
638            tracing::warn!(error = %e, "failed to decode inbound frame");
639        }
640    }
641}
642
643/// Connect to a Unix socket with retries.
644async fn connect_with_retry(
645    path: &Path,
646    max_attempts: u32,
647    backoff: Duration,
648) -> core_types::Result<UnixStream> {
649    let mut last_err = None;
650    for attempt in 1..=max_attempts {
651        match UnixStream::connect(path).await {
652            Ok(stream) => return Ok(stream),
653            Err(e) => {
654                tracing::debug!(
655                    attempt,
656                    max_attempts,
657                    path = %path.display(),
658                    error = %e,
659                    "connection attempt failed"
660                );
661                last_err = Some(e);
662                if attempt < max_attempts {
663                    tokio::time::sleep(backoff).await;
664                }
665            }
666        }
667    }
668    Err(core_types::Error::Ipc(format!(
669        "failed to connect to {} after {max_attempts} attempts: {}",
670        path.display(),
671        last_err.map_or_else(|| "unknown error".to_string(), |e| e.to_string())
672    )))
673}