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}