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.
73const SENSITIVE_ENV_KEYS: &[&str] = &["password"];
74
75/// A parsed real-time notification from OpenVPN.
76///
77/// The [`Debug`] implementation masks the values of known sensitive ENV
78/// keys (e.g. `password`) in [`Client`](Notification::Client) notifications,
79/// printing `<redacted>` instead.
80#[derive(Clone, PartialEq, Eq)]
81pub enum Notification {
82    /// A multi-line `>CLIENT:` notification (CONNECT, REAUTH, ESTABLISHED,
83    /// DISCONNECT). The header and all ENV key=value pairs are accumulated
84    /// into a single struct before this is emitted.
85    Client {
86        /// The client event sub-type.
87        event: ClientEvent,
88        /// Client ID (sequential, assigned by OpenVPN).
89        cid: u64,
90        /// Key ID (present for CONNECT/REAUTH, absent for ESTABLISHED/DISCONNECT).
91        kid: Option<u64>,
92        /// Accumulated ENV pairs, in order. Each `>CLIENT:ENV,key=val` line
93        /// becomes one `(key, val)` entry. The terminating `>CLIENT:ENV,END`
94        /// is consumed but not included.
95        env: Vec<(String, String)>,
96    },
97
98    /// A single-line `>CLIENT:ADDRESS` notification.
99    ClientAddress {
100        /// Client ID.
101        cid: u64,
102        /// Assigned virtual address.
103        addr: String,
104        /// Whether this is the primary address for the client.
105        primary: bool,
106    },
107
108    /// `>STATE:timestamp,name,desc,local_ip,remote_ip,remote_port,local_addr,local_port,local_ipv6`
109    ///
110    /// Field order per management-notes.txt: (a) timestamp, (b) state name,
111    /// (c) description, (d) TUN/TAP local IPv4, (e) remote server address,
112    /// (f) remote server port, (g) local address, (h) local port,
113    /// (i) TUN/TAP local IPv6.
114    State {
115        /// (a) Unix timestamp of the state change.
116        timestamp: u64,
117        /// (b) State name (e.g. `Connected`, `Reconnecting`).
118        name: OpenVpnState,
119        /// (c) Verbose description (mostly for RECONNECTING/EXITING).
120        description: String,
121        /// (d) TUN/TAP local IPv4 address (may be empty).
122        local_ip: String,
123        /// (e) Remote server address (may be empty).
124        remote_ip: String,
125        /// (f) Remote server port (may be empty).
126        remote_port: String,
127        /// (g) Local address (may be empty).
128        local_addr: String,
129        /// (h) Local port (may be empty).
130        local_port: String,
131        /// (i) TUN/TAP local IPv6 address (may be empty).
132        local_ipv6: String,
133    },
134
135    /// `>BYTECOUNT:bytes_in,bytes_out` (client mode)
136    ByteCount {
137        /// Bytes received since last reset.
138        bytes_in: u64,
139        /// Bytes sent since last reset.
140        bytes_out: u64,
141    },
142
143    /// `>BYTECOUNT_CLI:cid,bytes_in,bytes_out` (server mode, per-client)
144    ByteCountCli {
145        /// Client ID.
146        cid: u64,
147        /// Bytes received from this client.
148        bytes_in: u64,
149        /// Bytes sent to this client.
150        bytes_out: u64,
151    },
152
153    /// `>LOG:timestamp,level,message`
154    Log {
155        /// Unix timestamp of the log entry.
156        timestamp: u64,
157        /// Log severity level.
158        level: LogLevel,
159        /// The log message text.
160        message: String,
161    },
162
163    /// `>ECHO:timestamp,param_string`
164    Echo {
165        /// Unix timestamp.
166        timestamp: u64,
167        /// The echoed parameter string.
168        param: String,
169    },
170
171    /// `>HOLD:Waiting for hold release[:N]`
172    Hold {
173        /// The hold message text.
174        text: String,
175    },
176
177    /// `>FATAL:message`
178    Fatal {
179        /// The fatal error message.
180        message: String,
181    },
182
183    /// `>PKCS11ID-COUNT:count`
184    Pkcs11IdCount {
185        /// Number of available PKCS#11 identities.
186        count: u32,
187    },
188
189    /// `>NEED-OK:Need 'name' confirmation MSG:message`
190    NeedOk {
191        /// The prompt name.
192        name: String,
193        /// The prompt message to display.
194        message: String,
195    },
196
197    /// `>NEED-STR:Need 'name' input MSG:message`
198    NeedStr {
199        /// The prompt name.
200        name: String,
201        /// The prompt message to display.
202        message: String,
203    },
204
205    /// `>RSA_SIGN:base64_data`
206    RsaSign {
207        /// Base64-encoded data to be signed.
208        data: String,
209    },
210
211    /// `>REMOTE:host,port,protocol`
212    Remote {
213        /// Remote server hostname or IP.
214        host: String,
215        /// Remote server port.
216        port: u16,
217        /// Transport protocol.
218        protocol: crate::transport_protocol::TransportProtocol,
219    },
220
221    /// `>PROXY:index,proxy_type,host`
222    ///
223    /// Sent when OpenVPN needs proxy information (requires
224    /// `--management-query-proxy`). The management client responds
225    /// with a `proxy` command.
226    Proxy {
227        /// Connection index (1-based).
228        index: u32,
229        /// Proxy type string (e.g. `"TCP"`, `"UDP"`).
230        proxy_type: String,
231        /// Server hostname or IP to connect through.
232        host: String,
233    },
234
235    /// `>PASSWORD:...` — see [`PasswordNotification`] for the sub-types.
236    Password(PasswordNotification),
237
238    /// Fallback for any notification type not explicitly modeled above.
239    /// Kept for forward compatibility with future OpenVPN versions.
240    Simple {
241        /// The notification type keyword (e.g. `"BYTECOUNT"`).
242        kind: String,
243        /// Everything after the first colon.
244        payload: String,
245    },
246}
247
248/// Helper for Debug output: displays env entries, masking sensitive keys.
249struct RedactedEnv<'a>(&'a [(String, String)]);
250
251impl fmt::Debug for RedactedEnv<'_> {
252    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
253        f.debug_list()
254            .entries(self.0.iter().map(|(k, v)| {
255                if SENSITIVE_ENV_KEYS.contains(&k.as_str()) {
256                    (k.as_str(), "<redacted>")
257                } else {
258                    (k.as_str(), v.as_str())
259                }
260            }))
261            .finish()
262    }
263}
264
265impl fmt::Debug for Notification {
266    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
267        match self {
268            Self::Client {
269                event,
270                cid,
271                kid,
272                env,
273            } => f
274                .debug_struct("Client")
275                .field("event", event)
276                .field("cid", cid)
277                .field("kid", kid)
278                .field("env", &RedactedEnv(env))
279                .finish(),
280            Self::ClientAddress { cid, addr, primary } => f
281                .debug_struct("ClientAddress")
282                .field("cid", cid)
283                .field("addr", addr)
284                .field("primary", primary)
285                .finish(),
286            Self::State {
287                timestamp,
288                name,
289                description,
290                local_ip,
291                remote_ip,
292                remote_port,
293                local_addr,
294                local_port,
295                local_ipv6,
296            } => f
297                .debug_struct("State")
298                .field("timestamp", timestamp)
299                .field("name", name)
300                .field("description", description)
301                .field("local_ip", local_ip)
302                .field("remote_ip", remote_ip)
303                .field("remote_port", remote_port)
304                .field("local_addr", local_addr)
305                .field("local_port", local_port)
306                .field("local_ipv6", local_ipv6)
307                .finish(),
308            Self::ByteCount {
309                bytes_in,
310                bytes_out,
311            } => f
312                .debug_struct("ByteCount")
313                .field("bytes_in", bytes_in)
314                .field("bytes_out", bytes_out)
315                .finish(),
316            Self::ByteCountCli {
317                cid,
318                bytes_in,
319                bytes_out,
320            } => f
321                .debug_struct("ByteCountCli")
322                .field("cid", cid)
323                .field("bytes_in", bytes_in)
324                .field("bytes_out", bytes_out)
325                .finish(),
326            Self::Log {
327                timestamp,
328                level,
329                message,
330            } => f
331                .debug_struct("Log")
332                .field("timestamp", timestamp)
333                .field("level", level)
334                .field("message", message)
335                .finish(),
336            Self::Echo { timestamp, param } => f
337                .debug_struct("Echo")
338                .field("timestamp", timestamp)
339                .field("param", param)
340                .finish(),
341            Self::Hold { text } => f.debug_struct("Hold").field("text", text).finish(),
342            Self::Fatal { message } => f.debug_struct("Fatal").field("message", message).finish(),
343            Self::Pkcs11IdCount { count } => f
344                .debug_struct("Pkcs11IdCount")
345                .field("count", count)
346                .finish(),
347            Self::NeedOk { name, message } => f
348                .debug_struct("NeedOk")
349                .field("name", name)
350                .field("message", message)
351                .finish(),
352            Self::NeedStr { name, message } => f
353                .debug_struct("NeedStr")
354                .field("name", name)
355                .field("message", message)
356                .finish(),
357            Self::RsaSign { data } => f.debug_struct("RsaSign").field("data", data).finish(),
358            Self::Remote {
359                host,
360                port,
361                protocol,
362            } => f
363                .debug_struct("Remote")
364                .field("host", host)
365                .field("port", port)
366                .field("protocol", protocol)
367                .finish(),
368            Self::Proxy {
369                index,
370                proxy_type,
371                host,
372            } => f
373                .debug_struct("Proxy")
374                .field("index", index)
375                .field("proxy_type", proxy_type)
376                .field("host", host)
377                .finish(),
378            Self::Password(p) => f.debug_tuple("Password").field(p).finish(),
379            Self::Simple { kind, payload } => f
380                .debug_struct("Simple")
381                .field("kind", kind)
382                .field("payload", payload)
383                .finish(),
384        }
385    }
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391    use crate::transport_protocol::TransportProtocol;
392
393    // ── Debug redaction ──────────────────────────────────────────
394
395    #[test]
396    fn debug_redacts_password_env_key() {
397        let notif = Notification::Client {
398            event: ClientEvent::Connect,
399            cid: 1,
400            kid: Some(0),
401            env: vec![
402                ("common_name".to_string(), "alice".to_string()),
403                ("password".to_string(), "s3cret".to_string()),
404            ],
405        };
406        let dbg = format!("{notif:?}");
407        assert!(dbg.contains("alice"), "non-sensitive values should appear");
408        assert!(
409            !dbg.contains("s3cret"),
410            "password value must not appear in Debug output"
411        );
412        assert!(
413            dbg.contains("<redacted>"),
414            "password value should be replaced with <redacted>"
415        );
416    }
417
418    #[test]
419    fn debug_does_not_redact_non_sensitive_keys() {
420        let notif = Notification::Client {
421            event: ClientEvent::Disconnect,
422            cid: 5,
423            kid: None,
424            env: vec![("untrusted_ip".to_string(), "10.0.0.1".to_string())],
425        };
426        let dbg = format!("{notif:?}");
427        assert!(dbg.contains("10.0.0.1"));
428    }
429
430    // ── PasswordNotification variants ────────────────────────────
431
432    #[test]
433    fn password_notification_debug_redacts_token() {
434        let notif = PasswordNotification::AuthToken {
435            token: Redacted::new("super-secret-token".to_string()),
436        };
437        let dbg = format!("{notif:?}");
438        assert!(
439            !dbg.contains("super-secret-token"),
440            "auth token must not appear in Debug output"
441        );
442    }
443
444    #[test]
445    fn password_notification_eq() {
446        let a = PasswordNotification::NeedAuth {
447            auth_type: AuthType::Auth,
448        };
449        let b = PasswordNotification::NeedAuth {
450            auth_type: AuthType::Auth,
451        };
452        assert_eq!(a, b);
453
454        let c = PasswordNotification::NeedPassword {
455            auth_type: AuthType::PrivateKey,
456        };
457        assert_ne!(a, c);
458    }
459
460    #[test]
461    fn password_notification_static_challenge_fields() {
462        let sc = PasswordNotification::StaticChallenge {
463            echo: true,
464            response_concat: false,
465            challenge: "Enter PIN".to_string(),
466        };
467        if let PasswordNotification::StaticChallenge {
468            echo,
469            response_concat,
470            challenge,
471        } = sc
472        {
473            assert!(echo);
474            assert!(!response_concat);
475            assert_eq!(challenge, "Enter PIN");
476        } else {
477            panic!("wrong variant");
478        }
479    }
480
481    #[test]
482    fn password_notification_dynamic_challenge_fields() {
483        let dc = PasswordNotification::DynamicChallenge {
484            flags: "R,E".to_string(),
485            state_id: "abc123".to_string(),
486            username_b64: "dXNlcg==".to_string(),
487            challenge: "Enter OTP".to_string(),
488        };
489        if let PasswordNotification::DynamicChallenge {
490            flags,
491            state_id,
492            challenge,
493            ..
494        } = dc
495        {
496            assert_eq!(flags, "R,E");
497            assert_eq!(state_id, "abc123");
498            assert_eq!(challenge, "Enter OTP");
499        } else {
500            panic!("wrong variant");
501        }
502    }
503
504    // ── Notification Debug output for each variant ───────────────
505
506    #[test]
507    fn debug_state_notification() {
508        let notif = Notification::State {
509            timestamp: 1700000000,
510            name: OpenVpnState::Connected,
511            description: "SUCCESS".to_string(),
512            local_ip: "10.0.0.2".to_string(),
513            remote_ip: "1.2.3.4".to_string(),
514            remote_port: "1194".to_string(),
515            local_addr: "192.168.1.5".to_string(),
516            local_port: "51234".to_string(),
517            local_ipv6: String::new(),
518        };
519        let dbg = format!("{notif:?}");
520        assert!(dbg.contains("State"));
521        assert!(dbg.contains("Connected"));
522        assert!(dbg.contains("10.0.0.2"));
523    }
524
525    #[test]
526    fn debug_bytecount() {
527        let notif = Notification::ByteCount {
528            bytes_in: 1024,
529            bytes_out: 2048,
530        };
531        let dbg = format!("{notif:?}");
532        assert!(dbg.contains("1024"));
533        assert!(dbg.contains("2048"));
534    }
535
536    #[test]
537    fn debug_bytecount_cli() {
538        let notif = Notification::ByteCountCli {
539            cid: 7,
540            bytes_in: 100,
541            bytes_out: 200,
542        };
543        let dbg = format!("{notif:?}");
544        assert!(dbg.contains("ByteCountCli"));
545        assert!(dbg.contains("7"));
546    }
547
548    #[test]
549    fn debug_log() {
550        let notif = Notification::Log {
551            timestamp: 1700000000,
552            level: LogLevel::Warning,
553            message: "something happened".to_string(),
554        };
555        let dbg = format!("{notif:?}");
556        assert!(dbg.contains("Log"));
557        assert!(dbg.contains("something happened"));
558    }
559
560    #[test]
561    fn debug_echo() {
562        let notif = Notification::Echo {
563            timestamp: 123,
564            param: "push-update".to_string(),
565        };
566        let dbg = format!("{notif:?}");
567        assert!(dbg.contains("Echo"));
568        assert!(dbg.contains("push-update"));
569    }
570
571    #[test]
572    fn debug_hold() {
573        let notif = Notification::Hold {
574            text: "Waiting for hold release".to_string(),
575        };
576        let dbg = format!("{notif:?}");
577        assert!(dbg.contains("Hold"));
578    }
579
580    #[test]
581    fn debug_fatal() {
582        let notif = Notification::Fatal {
583            message: "cannot allocate TUN/TAP".to_string(),
584        };
585        let dbg = format!("{notif:?}");
586        assert!(dbg.contains("Fatal"));
587        assert!(dbg.contains("cannot allocate TUN/TAP"));
588    }
589
590    #[test]
591    fn debug_remote() {
592        let notif = Notification::Remote {
593            host: "vpn.example.com".to_string(),
594            port: 1194,
595            protocol: TransportProtocol::Udp,
596        };
597        let dbg = format!("{notif:?}");
598        assert!(dbg.contains("Remote"));
599        assert!(dbg.contains("vpn.example.com"));
600    }
601
602    #[test]
603    fn debug_proxy() {
604        let notif = Notification::Proxy {
605            index: 1,
606            proxy_type: "TCP".to_string(),
607            host: "proxy.local".to_string(),
608        };
609        let dbg = format!("{notif:?}");
610        assert!(dbg.contains("Proxy"));
611        assert!(dbg.contains("proxy.local"));
612    }
613
614    #[test]
615    fn debug_simple_fallback() {
616        let notif = Notification::Simple {
617            kind: "FUTURE_TYPE".to_string(),
618            payload: "some data".to_string(),
619        };
620        let dbg = format!("{notif:?}");
621        assert!(dbg.contains("FUTURE_TYPE"));
622        assert!(dbg.contains("some data"));
623    }
624
625    #[test]
626    fn debug_client_address() {
627        let notif = Notification::ClientAddress {
628            cid: 42,
629            addr: "10.8.0.6".to_string(),
630            primary: true,
631        };
632        let dbg = format!("{notif:?}");
633        assert!(dbg.contains("ClientAddress"));
634        assert!(dbg.contains("10.8.0.6"));
635        assert!(dbg.contains("true"));
636    }
637
638    // ── OvpnMessage variants ─────────────────────────────────────
639
640    #[test]
641    fn ovpn_message_eq() {
642        assert_eq!(
643            OvpnMessage::Success("pid=42".to_string()),
644            OvpnMessage::Success("pid=42".to_string()),
645        );
646        assert_ne!(
647            OvpnMessage::Success("a".to_string()),
648            OvpnMessage::Error("a".to_string()),
649        );
650    }
651
652    #[test]
653    fn ovpn_message_pkcs11_entry() {
654        let msg = OvpnMessage::Pkcs11IdEntry {
655            index: "0".to_string(),
656            id: "slot_0".to_string(),
657            blob: "AQID".to_string(),
658        };
659        let dbg = format!("{msg:?}");
660        assert!(dbg.contains("Pkcs11IdEntry"));
661        assert!(dbg.contains("slot_0"));
662    }
663
664    #[test]
665    fn ovpn_message_password_prompt() {
666        assert_eq!(OvpnMessage::PasswordPrompt, OvpnMessage::PasswordPrompt);
667    }
668
669    #[test]
670    fn ovpn_message_unrecognized() {
671        let msg = OvpnMessage::Unrecognized {
672            line: "garbage".to_string(),
673            kind: crate::unrecognized::UnrecognizedKind::UnexpectedLine,
674        };
675        let dbg = format!("{msg:?}");
676        assert!(dbg.contains("garbage"));
677    }
678}
679
680/// A fully decoded message from the OpenVPN management interface.
681#[derive(Debug, Clone, PartialEq, Eq)]
682pub enum OvpnMessage {
683    /// A success response: `SUCCESS: [text]`.
684    Success(String),
685
686    /// An error response: `ERROR: [text]`.
687    Error(String),
688
689    /// A multi-line response block (from `status`, `version`, `help`, etc.).
690    /// The terminating `END` line is consumed but not included.
691    MultiLine(Vec<String>),
692
693    /// Parsed response from `>PKCS11ID-ENTRY:` notification (sent by
694    /// `pkcs11-id-get`). Wire: `>PKCS11ID-ENTRY:'index', ID:'id', BLOB:'blob'`
695    Pkcs11IdEntry {
696        /// Certificate index.
697        index: String,
698        /// PKCS#11 identifier.
699        id: String,
700        /// Base64-encoded certificate blob.
701        blob: String,
702    },
703
704    /// A real-time notification, either single-line or accumulated multi-line.
705    Notification(Notification),
706
707    /// The `>INFO:` banner sent when the management socket first connects.
708    /// Technically a notification, but surfaced separately since it's always
709    /// the first thing you see and is useful for version detection.
710    Info(String),
711
712    /// Management interface password prompt. Sent when `--management` is
713    /// configured with a password file. The client must respond with the
714    /// password (via [`crate::OvpnCommand::ManagementPassword`]) before any
715    /// commands are accepted.
716    PasswordPrompt,
717
718    /// A line that could not be classified into any known message type.
719    /// Contains the raw line and a description of what went wrong.
720    Unrecognized {
721        /// The raw line that could not be parsed.
722        line: String,
723        /// Why the line was not recognized.
724        kind: crate::unrecognized::UnrecognizedKind,
725    },
726}