Skip to main content

openvpn_mgmt_codec/
message.rs

1use std::fmt;
2
3use crate::auth::AuthType;
4use crate::client_event::ClientEvent;
5use crate::log_level::LogLevel;
6use crate::openvpn_state::OpenVpnState;
7use crate::redacted::Redacted;
8
9/// Sub-types of `>PASSWORD:` notifications. The password notification
10/// has several distinct forms with completely different structures.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum PasswordNotification {
13    /// `>PASSWORD:Need 'Auth' username/password`
14    NeedAuth {
15        /// The credential set being requested.
16        auth_type: AuthType,
17    },
18
19    /// `>PASSWORD:Need 'Private Key' password`
20    NeedPassword {
21        /// The credential set being requested.
22        auth_type: AuthType,
23    },
24
25    /// `>PASSWORD:Verification Failed: 'Auth'`
26    VerificationFailed {
27        /// The credential set that failed verification.
28        auth_type: AuthType,
29    },
30
31    /// Static challenge: `>PASSWORD:Need 'Auth' username/password SC:{flag},{challenge}`
32    /// The flag is a multi-bit integer: bit 0 = ECHO, bit 1 = FORMAT.
33    StaticChallenge {
34        /// Whether to echo the user's response (bit 0 of the SC flag).
35        echo: bool,
36        /// Whether the response should be concatenated with the password
37        /// as plain text (bit 1 of the SC flag). When `false`, the response
38        /// and password are base64-encoded per the SCRV1 format.
39        response_concat: bool,
40        /// The challenge text presented to the user.
41        challenge: String,
42    },
43
44    /// `>PASSWORD:Auth-Token:{token}`
45    ///
46    /// Pushed by the server when `--auth-token` is active. The client should
47    /// store this token and use it in place of the original password on
48    /// subsequent re-authentications.
49    ///
50    /// Source: OpenVPN `manage.c` — `management_auth_token()`.
51    AuthToken {
52        /// The opaque auth-token string (redacted in debug output).
53        token: Redacted,
54    },
55
56    /// Dynamic challenge (CRV1):
57    /// `>PASSWORD:Verification Failed: 'Auth' ['CRV1:{flags}:{state_id}:{username_b64}:{challenge}']`
58    DynamicChallenge {
59        /// Comma-separated CRV1 flags.
60        flags: String,
61        /// Opaque state identifier for the auth backend.
62        state_id: String,
63        /// Base64-encoded username. Note: visible in [`Debug`] output — callers
64        /// handling PII should avoid logging this variant without filtering.
65        username_b64: String,
66        /// The challenge text presented to the user.
67        challenge: String,
68    },
69}
70
71/// ENV key names whose values are masked in `Debug` output to prevent
72/// accidental exposure in logs. Used by `RedactedEnv` below (invoked from
73/// `derive_more::Debug` on [`Notification::Client::env`]).
74#[allow(dead_code)] // used via derive_more::Debug attribute
75const SENSITIVE_ENV_KEYS: &[&str] = &["password"];
76
77/// A parsed real-time notification from OpenVPN.
78///
79/// The [`Debug`] implementation masks the values of known sensitive ENV
80/// keys (e.g. `password`) in [`Client`](Notification::Client) notifications,
81/// printing `<redacted>` instead.
82#[derive(derive_more::Debug, Clone, PartialEq, Eq)]
83pub enum Notification {
84    /// A multi-line `>CLIENT:` notification (CONNECT, REAUTH, ESTABLISHED,
85    /// DISCONNECT). The header and all ENV key=value pairs are accumulated
86    /// into a single struct before this is emitted.
87    Client {
88        /// The client event sub-type.
89        event: ClientEvent,
90        /// Client ID (sequential, assigned by OpenVPN).
91        cid: u64,
92        /// Key ID (present for CONNECT/REAUTH, absent for ESTABLISHED/DISCONNECT).
93        kid: Option<u64>,
94        /// Accumulated ENV pairs, in order. Each `>CLIENT:ENV,key=val` line
95        /// becomes one `(key, val)` entry. The terminating `>CLIENT:ENV,END`
96        /// is consumed but not included.
97        #[debug("{:?}", RedactedEnv(env))]
98        env: Vec<(String, String)>,
99    },
100
101    /// A single-line `>CLIENT:ADDRESS` notification.
102    ClientAddress {
103        /// Client ID.
104        cid: u64,
105        /// Assigned virtual address.
106        addr: String,
107        /// Whether this is the primary address for the client.
108        primary: bool,
109    },
110
111    /// `>STATE:timestamp,name,desc,local_ip,remote_ip,remote_port,local_addr,local_port,local_ipv6`
112    ///
113    /// Field order per management-notes.txt: (a) timestamp, (b) state name,
114    /// (c) description, (d) TUN/TAP local IPv4, (e) remote server address,
115    /// (f) remote server port, (g) local address, (h) local port,
116    /// (i) TUN/TAP local IPv6.
117    State {
118        /// (a) Unix timestamp of the state change.
119        timestamp: u64,
120        /// (b) State name (e.g. `Connected`, `Reconnecting`).
121        name: OpenVpnState,
122        /// (c) Verbose description (mostly for RECONNECTING/EXITING).
123        description: String,
124        /// (d) TUN/TAP local IPv4 address (may be empty).
125        local_ip: String,
126        /// (e) Remote server address (may be empty).
127        remote_ip: String,
128        /// (f) Remote server port (empty in many states).
129        remote_port: Option<u16>,
130        /// (g) Local address (may be empty).
131        local_addr: String,
132        /// (h) Local port (empty in many states).
133        local_port: Option<u16>,
134        /// (i) TUN/TAP local IPv6 address (may be empty).
135        local_ipv6: String,
136    },
137
138    /// `>BYTECOUNT:bytes_in,bytes_out` (client mode)
139    ByteCount {
140        /// Bytes received since last reset.
141        bytes_in: u64,
142        /// Bytes sent since last reset.
143        bytes_out: u64,
144    },
145
146    /// `>BYTECOUNT_CLI:cid,bytes_in,bytes_out` (server mode, per-client)
147    ByteCountCli {
148        /// Client ID.
149        cid: u64,
150        /// Bytes received from this client.
151        bytes_in: u64,
152        /// Bytes sent to this client.
153        bytes_out: u64,
154    },
155
156    /// `>LOG:timestamp,level,message`
157    Log {
158        /// Unix timestamp of the log entry.
159        timestamp: u64,
160        /// Log severity level.
161        level: LogLevel,
162        /// The log message text.
163        message: String,
164    },
165
166    /// `>ECHO:timestamp,param_string`
167    Echo {
168        /// Unix timestamp.
169        timestamp: u64,
170        /// The echoed parameter string.
171        param: String,
172    },
173
174    /// `>HOLD:Waiting for hold release[:N]`
175    Hold {
176        /// The hold message text.
177        text: String,
178    },
179
180    /// `>FATAL:message`
181    Fatal {
182        /// The fatal error message.
183        message: String,
184    },
185
186    /// `>PKCS11ID-COUNT:count`
187    Pkcs11IdCount {
188        /// Number of available PKCS#11 identities.
189        count: u32,
190    },
191
192    /// `>NEED-OK:Need 'name' confirmation MSG:message`
193    NeedOk {
194        /// The prompt name.
195        name: String,
196        /// The prompt message to display.
197        message: String,
198    },
199
200    /// `>NEED-STR:Need 'name' input MSG:message`
201    NeedStr {
202        /// The prompt name.
203        name: String,
204        /// The prompt message to display.
205        message: String,
206    },
207
208    /// `>RSA_SIGN:base64_data`
209    RsaSign {
210        /// Base64-encoded data to be signed.
211        data: String,
212    },
213
214    /// `>REMOTE:host,port,protocol`
215    Remote {
216        /// Remote server hostname or IP.
217        host: String,
218        /// Remote server port.
219        port: u16,
220        /// Transport protocol.
221        protocol: crate::transport_protocol::TransportProtocol,
222    },
223
224    /// `>PROXY:index,proxy_type,host`
225    ///
226    /// Sent when OpenVPN needs proxy information (requires
227    /// `--management-query-proxy`). The management client responds
228    /// with a `proxy` command.
229    Proxy {
230        /// Connection index (1-based).
231        index: u32,
232        /// Proxy type (e.g. `TCP`, `UDP`).
233        proxy_type: crate::transport_protocol::TransportProtocol,
234        /// Server hostname or IP to connect through.
235        host: String,
236    },
237
238    /// `>PASSWORD:...` — see [`PasswordNotification`] for the sub-types.
239    Password(PasswordNotification),
240
241    /// Fallback for any notification type not explicitly modeled above.
242    /// Kept for forward compatibility with future OpenVPN versions.
243    Simple {
244        /// The notification type keyword (e.g. `"BYTECOUNT"`).
245        kind: String,
246        /// Everything after the first colon.
247        payload: String,
248    },
249}
250
251/// Helper for Debug output: displays env entries, masking sensitive keys.
252/// Constructed by `derive_more::Debug` on [`Notification::Client::env`].
253#[allow(dead_code)] // used via derive_more::Debug attribute
254struct RedactedEnv<'a>(&'a [(String, String)]);
255
256impl fmt::Debug for RedactedEnv<'_> {
257    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
258        f.debug_list()
259            .entries(self.0.iter().map(|(k, v)| {
260                if SENSITIVE_ENV_KEYS.contains(&k.as_str()) {
261                    (k.as_str(), "<redacted>")
262                } else {
263                    (k.as_str(), v.as_str())
264                }
265            }))
266            .finish()
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273    use crate::transport_protocol::TransportProtocol;
274    // --- Debug redaction ---
275
276    #[test]
277    fn debug_redacts_password_env_key() {
278        let notif = Notification::Client {
279            event: ClientEvent::Connect,
280            cid: 1,
281            kid: Some(0),
282            env: vec![
283                ("common_name".to_string(), "alice".to_string()),
284                ("password".to_string(), "s3cret".to_string()),
285            ],
286        };
287        let dbg = format!("{notif:?}");
288        assert!(dbg.contains("alice"), "non-sensitive values should appear");
289        assert!(
290            !dbg.contains("s3cret"),
291            "password value must not appear in Debug output"
292        );
293        assert!(
294            dbg.contains("<redacted>"),
295            "password value should be replaced with <redacted>"
296        );
297    }
298
299    #[test]
300    fn debug_does_not_redact_non_sensitive_keys() {
301        let notif = Notification::Client {
302            event: ClientEvent::Disconnect,
303            cid: 5,
304            kid: None,
305            env: vec![("untrusted_ip".to_string(), "10.0.0.1".to_string())],
306        };
307        let dbg = format!("{notif:?}");
308        assert!(dbg.contains("10.0.0.1"));
309    }
310
311    // --- PasswordNotification variants ---
312
313    #[test]
314    fn password_notification_debug_redacts_token() {
315        let notif = PasswordNotification::AuthToken {
316            token: Redacted::new("super-secret-token".to_string()),
317        };
318        let dbg = format!("{notif:?}");
319        assert!(
320            !dbg.contains("super-secret-token"),
321            "auth token must not appear in Debug output"
322        );
323    }
324
325    #[test]
326    fn password_notification_eq() {
327        let a = PasswordNotification::NeedAuth {
328            auth_type: AuthType::Auth,
329        };
330        let b = PasswordNotification::NeedAuth {
331            auth_type: AuthType::Auth,
332        };
333        assert_eq!(a, b);
334
335        let c = PasswordNotification::NeedPassword {
336            auth_type: AuthType::PrivateKey,
337        };
338        assert_ne!(a, c);
339    }
340
341    #[test]
342    fn password_notification_static_challenge_fields() {
343        let sc = PasswordNotification::StaticChallenge {
344            echo: true,
345            response_concat: false,
346            challenge: "Enter PIN".to_string(),
347        };
348        if let PasswordNotification::StaticChallenge {
349            echo,
350            response_concat,
351            challenge,
352        } = sc
353        {
354            assert!(echo);
355            assert!(!response_concat);
356            assert_eq!(challenge, "Enter PIN");
357        } else {
358            panic!("wrong variant");
359        }
360    }
361
362    #[test]
363    fn password_notification_dynamic_challenge_fields() {
364        let dc = PasswordNotification::DynamicChallenge {
365            flags: "R,E".to_string(),
366            state_id: "abc123".to_string(),
367            username_b64: "dXNlcg==".to_string(),
368            challenge: "Enter OTP".to_string(),
369        };
370        if let PasswordNotification::DynamicChallenge {
371            flags,
372            state_id,
373            challenge,
374            ..
375        } = dc
376        {
377            assert_eq!(flags, "R,E");
378            assert_eq!(state_id, "abc123");
379            assert_eq!(challenge, "Enter OTP");
380        } else {
381            panic!("wrong variant");
382        }
383    }
384
385    // --- Notification Debug output for each variant ---
386
387    #[test]
388    fn debug_state_notification() {
389        let notif = Notification::State {
390            timestamp: 1700000000,
391            name: OpenVpnState::Connected,
392            description: "SUCCESS".to_string(),
393            local_ip: "10.0.0.2".to_string(),
394            remote_ip: "1.2.3.4".to_string(),
395            remote_port: Some(1194),
396            local_addr: "192.168.1.5".to_string(),
397            local_port: Some(51234),
398            local_ipv6: String::new(),
399        };
400        let dbg = format!("{notif:?}");
401        assert!(dbg.contains("State"));
402        assert!(dbg.contains("Connected"));
403        assert!(dbg.contains("10.0.0.2"));
404    }
405
406    #[test]
407    fn debug_bytecount() {
408        let notif = Notification::ByteCount {
409            bytes_in: 1024,
410            bytes_out: 2048,
411        };
412        let dbg = format!("{notif:?}");
413        assert!(dbg.contains("1024"));
414        assert!(dbg.contains("2048"));
415    }
416
417    #[test]
418    fn debug_bytecount_cli() {
419        let notif = Notification::ByteCountCli {
420            cid: 7,
421            bytes_in: 100,
422            bytes_out: 200,
423        };
424        let dbg = format!("{notif:?}");
425        assert!(dbg.contains("ByteCountCli"));
426        assert!(dbg.contains("7"));
427    }
428
429    #[test]
430    fn debug_log() {
431        let notif = Notification::Log {
432            timestamp: 1700000000,
433            level: LogLevel::Warning,
434            message: "something happened".to_string(),
435        };
436        let dbg = format!("{notif:?}");
437        assert!(dbg.contains("Log"));
438        assert!(dbg.contains("something happened"));
439    }
440
441    #[test]
442    fn debug_echo() {
443        let notif = Notification::Echo {
444            timestamp: 123,
445            param: "push-update".to_string(),
446        };
447        let dbg = format!("{notif:?}");
448        assert!(dbg.contains("Echo"));
449        assert!(dbg.contains("push-update"));
450    }
451
452    #[test]
453    fn debug_hold() {
454        let notif = Notification::Hold {
455            text: "Waiting for hold release".to_string(),
456        };
457        let dbg = format!("{notif:?}");
458        assert!(dbg.contains("Hold"));
459    }
460
461    #[test]
462    fn debug_fatal() {
463        let notif = Notification::Fatal {
464            message: "cannot allocate TUN/TAP".to_string(),
465        };
466        let dbg = format!("{notif:?}");
467        assert!(dbg.contains("Fatal"));
468        assert!(dbg.contains("cannot allocate TUN/TAP"));
469    }
470
471    #[test]
472    fn debug_remote() {
473        let notif = Notification::Remote {
474            host: "vpn.example.com".to_string(),
475            port: 1194,
476            protocol: TransportProtocol::Udp,
477        };
478        let dbg = format!("{notif:?}");
479        assert!(dbg.contains("Remote"));
480        assert!(dbg.contains("vpn.example.com"));
481    }
482
483    #[test]
484    fn debug_proxy() {
485        let notif = Notification::Proxy {
486            index: 1,
487            proxy_type: TransportProtocol::Tcp,
488            host: "proxy.local".to_string(),
489        };
490        let dbg = format!("{notif:?}");
491        assert!(dbg.contains("Proxy"));
492        assert!(dbg.contains("proxy.local"));
493    }
494
495    #[test]
496    fn debug_simple_fallback() {
497        let notif = Notification::Simple {
498            kind: "FUTURE_TYPE".to_string(),
499            payload: "some data".to_string(),
500        };
501        let dbg = format!("{notif:?}");
502        assert!(dbg.contains("FUTURE_TYPE"));
503        assert!(dbg.contains("some data"));
504    }
505
506    #[test]
507    fn debug_client_address() {
508        let notif = Notification::ClientAddress {
509            cid: 42,
510            addr: "10.8.0.6".to_string(),
511            primary: true,
512        };
513        let dbg = format!("{notif:?}");
514        assert!(dbg.contains("ClientAddress"));
515        assert!(dbg.contains("10.8.0.6"));
516        assert!(dbg.contains("true"));
517    }
518
519    // --- OvpnMessage variants ---
520
521    #[test]
522    fn ovpn_message_eq() {
523        assert_eq!(
524            OvpnMessage::Success("pid=42".to_string()),
525            OvpnMessage::Success("pid=42".to_string()),
526        );
527        assert_ne!(
528            OvpnMessage::Success("a".to_string()),
529            OvpnMessage::Error("a".to_string()),
530        );
531    }
532
533    #[test]
534    fn ovpn_message_pkcs11_entry() {
535        let msg = OvpnMessage::Pkcs11IdEntry {
536            index: "0".to_string(),
537            id: "slot_0".to_string(),
538            blob: "AQID".to_string(),
539        };
540        let dbg = format!("{msg:?}");
541        assert!(dbg.contains("Pkcs11IdEntry"));
542        assert!(dbg.contains("slot_0"));
543    }
544
545    #[test]
546    fn ovpn_message_password_prompt() {
547        assert_eq!(OvpnMessage::PasswordPrompt, OvpnMessage::PasswordPrompt);
548    }
549
550    #[test]
551    fn ovpn_message_unrecognized() {
552        let msg = OvpnMessage::Unrecognized {
553            line: "garbage".to_string(),
554            kind: crate::unrecognized::UnrecognizedKind::UnexpectedLine,
555        };
556        let dbg = format!("{msg:?}");
557        assert!(dbg.contains("garbage"));
558    }
559}
560
561/// A fully decoded message from the OpenVPN management interface.
562#[derive(Debug, Clone, PartialEq, Eq)]
563pub enum OvpnMessage {
564    /// A success response: `SUCCESS: [text]`.
565    Success(String),
566
567    /// An error response: `ERROR: [text]`.
568    Error(String),
569
570    /// A multi-line response block (from `status`, `version`, `help`, etc.).
571    /// The terminating `END` line is consumed but not included.
572    MultiLine(Vec<String>),
573
574    /// Parsed response from `>PKCS11ID-ENTRY:` notification (sent by
575    /// `pkcs11-id-get`). Wire: `>PKCS11ID-ENTRY:'index', ID:'id', BLOB:'blob'`
576    Pkcs11IdEntry {
577        /// Certificate index.
578        index: String,
579        /// PKCS#11 identifier.
580        id: String,
581        /// Base64-encoded certificate blob.
582        blob: String,
583    },
584
585    /// A real-time notification, either single-line or accumulated multi-line.
586    Notification(Notification),
587
588    /// The `>INFO:` banner sent when the management socket first connects.
589    /// Technically a notification, but surfaced separately since it's always
590    /// the first thing you see and is useful for version detection.
591    Info(String),
592
593    /// Management interface password prompt. Sent when `--management` is
594    /// configured with a password file. The client must respond with the
595    /// password (via [`crate::OvpnCommand::ManagementPassword`]) before any
596    /// commands are accepted.
597    PasswordPrompt,
598
599    /// A line that could not be classified into any known message type.
600    /// Contains the raw line and a description of what went wrong.
601    Unrecognized {
602        /// The raw line that could not be parsed.
603        line: String,
604        /// Why the line was not recognized.
605        kind: crate::unrecognized::UnrecognizedKind,
606    },
607}