Skip to main content

openvpn_mgmt_codec/
stream.rs

1//! Helpers for categorizing [`OvpnMessage`]s into responses and notifications.
2//!
3//! The raw codec yields [`OvpnMessage`] variants, and callers typically
4//! need to branch on "is this a response to a command I sent?" vs. "is
5//! this an asynchronous notification?". This module provides
6//! [`ManagementEvent`] (the two-variant enum) and [`ClassifyExt`] (an
7//! extension trait that adds [`.classify()`](ClassifyExt::classify) to
8//! any stream of codec results).
9//!
10//! # Notification interleaving
11//!
12//! [`ManagementEvent::Notification`] can appear **between** sending a
13//! command and receiving its [`ManagementEvent::Response`]. Consumers
14//! should always handle both variants in their stream loop — do not
15//! assume the next item after sending a command will be its response.
16//!
17//! # Example
18//!
19//! ```no_run
20//! use tokio::net::TcpStream;
21//! use tokio_util::codec::Framed;
22//! use futures::{SinkExt, StreamExt};
23//! use openvpn_mgmt_codec::{OvpnCodec, OvpnCommand, StatusFormat};
24//! use openvpn_mgmt_codec::stream::{ManagementEvent, ClassifyExt};
25//!
26//! # async fn example() -> anyhow::Result<()> {
27//! let stream = TcpStream::connect("127.0.0.1:7505").await?;
28//! let framed = Framed::new(stream, OvpnCodec::new());
29//! let (mut sink, raw_stream) = framed.split();
30//!
31//! let mut mgmt = raw_stream.classify();
32//!
33//! sink.send(OvpnCommand::Status(StatusFormat::V3)).await?;
34//!
35//! while let Some(event) = mgmt.next().await {
36//!     match event? {
37//!         ManagementEvent::Notification(notification) => {
38//!             println!("async notification: {notification:?}");
39//!         }
40//!         ManagementEvent::Response(msg) => {
41//!             println!("command response: {msg:?}");
42//!         }
43//!     }
44//! }
45//! # Ok(())
46//! # }
47//! ```
48
49use std::io;
50use std::pin::Pin;
51use std::task::{Context, Poll};
52
53use futures_core::Stream;
54use pin_project_lite::pin_project;
55
56use crate::message::{Notification, OvpnMessage};
57
58/// A management-interface event, categorized as either a command response
59/// or an asynchronous notification.
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub enum ManagementEvent {
62    /// A command response: [`OvpnMessage::Success`], [`OvpnMessage::Error`],
63    /// [`OvpnMessage::MultiLine`], [`OvpnMessage::Pkcs11IdEntry`],
64    /// [`OvpnMessage::Info`], [`OvpnMessage::PasswordPrompt`], or
65    /// [`OvpnMessage::Unrecognized`].
66    Response(OvpnMessage),
67
68    /// A real-time notification from the daemon.
69    Notification(Notification),
70}
71
72impl From<OvpnMessage> for ManagementEvent {
73    fn from(msg: OvpnMessage) -> Self {
74        match msg {
75            OvpnMessage::Notification(notification) => Self::Notification(notification),
76            other => Self::Response(other),
77        }
78    }
79}
80
81pin_project! {
82    /// A stream of [`ManagementEvent`]s, produced by
83    /// [`ClassifyExt::classify`].
84    ///
85    /// Each incoming `Result<OvpnMessage, io::Error>` is mapped through
86    /// the [`From<OvpnMessage> for ManagementEvent`] conversion, splitting
87    /// notifications from command responses.
88    pub struct Classified<S> {
89        #[pin]
90        inner: S,
91    }
92}
93
94impl<S> Stream for Classified<S>
95where
96    S: Stream<Item = Result<OvpnMessage, io::Error>>,
97{
98    type Item = Result<ManagementEvent, io::Error>;
99
100    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
101        self.project()
102            .inner
103            .poll_next(cx)
104            .map(|opt| opt.map(|result| result.map(ManagementEvent::from)))
105    }
106
107    fn size_hint(&self) -> (usize, Option<usize>) {
108        self.inner.size_hint()
109    }
110}
111
112/// Extension trait that adds [`.classify()`](ClassifyExt::classify) to
113/// any stream of `Result<OvpnMessage, io::Error>`.
114///
115/// # Example
116///
117/// ```no_run
118/// use anyhow::Context;
119/// # async fn example() -> anyhow::Result<()> {
120/// use tokio::net::TcpStream;
121/// use tokio_util::codec::Framed;
122/// use futures::{SinkExt, StreamExt};
123/// use openvpn_mgmt_codec::{OvpnCodec, OvpnCommand};
124///
125/// let stream = TcpStream::connect("127.0.0.1:7505").await?;
126/// let mut framed = Framed::new(stream, OvpnCodec::new());
127///
128/// // Send a command and read the response with a timeout.
129/// framed.send(OvpnCommand::Pid).await?;
130/// let response = tokio::time::timeout(
131///     std::time::Duration::from_secs(5),
132///     framed.next(),
133/// ).await
134///  .context("stream ended")?;
135///
136/// println!("got: {response:?}");
137/// # Ok(())
138/// # }
139/// ```
140///
141/// # Reconnection with backoff
142///
143/// ```no_run
144/// # async fn example() -> anyhow::Result<()> {
145/// use tokio::net::TcpStream;
146/// use tokio_util::codec::Framed;
147/// use futures::StreamExt;
148/// use openvpn_mgmt_codec::{OvpnCodec, OvpnMessage};
149///
150/// let mut backoff = std::time::Duration::from_secs(1);
151/// loop {
152///     match TcpStream::connect("127.0.0.1:7505").await {
153///         Ok(stream) => {
154///             backoff = std::time::Duration::from_secs(1); // reset
155///             let mut framed = Framed::new(stream, OvpnCodec::new());
156///             while let Some(msg) = framed.next().await {
157///                 match msg {
158///                     Ok(msg) => println!("{msg:?}"),
159///                     Err(error) => { eprintln!("decode error: {error}"); break; }
160///                 }
161///             }
162///             eprintln!("connection closed, reconnecting...");
163///         }
164///         Err(error) => {
165///             eprintln!("connect failed: {error}, retrying in {backoff:?}");
166///         }
167///     }
168///     tokio::time::sleep(backoff).await;
169///     backoff = (backoff * 2).min(std::time::Duration::from_secs(30));
170/// }
171/// # }
172/// ```
173///
174/// # Detecting connection loss via `>FATAL:`
175///
176/// ```no_run
177/// # async fn example() -> anyhow::Result<()> {
178/// use tokio::net::TcpStream;
179/// use tokio_util::codec::Framed;
180/// use futures::StreamExt;
181/// use openvpn_mgmt_codec::{OvpnCodec, OvpnMessage, Notification};
182///
183/// let stream = TcpStream::connect("127.0.0.1:7505").await?;
184/// let mut framed = Framed::new(stream, OvpnCodec::new());
185///
186/// while let Some(msg) = framed.next().await {
187///     match msg? {
188///         OvpnMessage::Notification(Notification::Fatal { message }) => {
189///             eprintln!("OpenVPN fatal: {message}");
190///             // Trigger graceful shutdown / reconnection.
191///             break;
192///         }
193///         other => println!("{other:?}"),
194///     }
195/// }
196/// // Stream ended — either FATAL or the daemon closed the connection.
197/// // In both cases, you should reconnect (see reconnection example above).
198/// # Ok(())
199/// # }
200/// ```
201pub trait ClassifyExt: Stream<Item = Result<OvpnMessage, io::Error>> + Sized {
202    /// Classify each [`OvpnMessage`] into a [`ManagementEvent`],
203    /// splitting notifications from command responses.
204    fn classify(self) -> Classified<Self> {
205        Classified { inner: self }
206    }
207}
208
209impl<S: Stream<Item = Result<OvpnMessage, io::Error>>> ClassifyExt for S {}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use crate::message::Notification;
215
216    #[test]
217    fn success_maps_to_response() {
218        let msg = OvpnMessage::Success("pid=42".to_string());
219        let event: ManagementEvent = msg.into();
220        assert_eq!(
221            event,
222            ManagementEvent::Response(OvpnMessage::Success("pid=42".to_string()))
223        );
224    }
225
226    #[test]
227    fn notification_maps_to_notification() {
228        let msg = OvpnMessage::Notification(Notification::Hold {
229            text: "Waiting".to_string(),
230        });
231        let event: ManagementEvent = msg.into();
232        assert!(matches!(
233            event,
234            ManagementEvent::Notification(Notification::Hold { .. })
235        ));
236    }
237
238    #[test]
239    fn info_maps_to_response() {
240        let msg = OvpnMessage::Info("banner".to_string());
241        let event: ManagementEvent = msg.into();
242        assert!(matches!(
243            event,
244            ManagementEvent::Response(OvpnMessage::Info(_))
245        ));
246    }
247}