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}