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}