Skip to main content

openvpn_mgmt_codec/
message.rs

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