Skip to main content

openvpn_mgmt_codec/
command.rs

1use std::fmt;
2use std::str::FromStr;
3
4use crate::{
5    auth::{AuthRetryMode, AuthType, ParseAuthRetryModeError},
6    kill_target::KillTarget,
7    need_ok::NeedOkResponse,
8    proxy_action::ProxyAction,
9    redacted::Redacted,
10    remote_action::RemoteAction,
11    signal::{ParseSignalError, Signal},
12    status_format::StatusFormat,
13    stream_mode::{ParseStreamModeError, StreamMode},
14    transport_protocol::TransportProtocol,
15};
16use tracing::warn;
17
18/// Error returned when parsing a command string fails.
19#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
20pub enum CommandParseError {
21    /// Unrecognized signal name.
22    #[error(transparent)]
23    Signal(#[from] ParseSignalError),
24
25    /// Unrecognized stream mode.
26    #[error(transparent)]
27    StreamMode(#[from] ParseStreamModeError),
28
29    /// Unrecognized auth retry mode.
30    #[error(transparent)]
31    AuthRetryMode(#[from] ParseAuthRetryModeError),
32
33    /// Malformed command syntax (wrong number of arguments, non-numeric
34    /// values where numbers are expected, etc.).
35    #[error("{0}")]
36    Syntax(String),
37}
38
39/// Range selector for `remote-entry-get`.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum RemoteEntryRange {
42    /// A single entry by index.
43    Single(u32),
44    /// A range of entries `[from, to)`.
45    Range {
46        /// Start index (inclusive).
47        from: u32,
48        /// End index (exclusive).
49        to: u32,
50    },
51    /// All entries.
52    All,
53}
54
55impl fmt::Display for RemoteEntryRange {
56    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57        match self {
58            Self::Single(i) => write!(f, "{i}"),
59            Self::Range { from, to } => write!(f, "{from} {to}"),
60            Self::All => f.write_str("all"),
61        }
62    }
63}
64
65/// Every command the management interface accepts, modeled as a typed enum.
66///
67/// The encoder handles all serialization — escaping, quoting, multi-line
68/// block framing — so callers never assemble raw strings. The `Raw` variant
69/// exists as an escape hatch for commands not yet modeled here.
70///
71/// Sensitive fields (passwords, tokens, challenge responses) are wrapped in
72/// [`Redacted`] so they are masked in [`Debug`] and [`Display`](std::fmt::Display)
73/// output. Use [`Redacted::expose`] to access the raw value for wire encoding.
74#[derive(Debug, Clone, PartialEq, Eq, strum::IntoStaticStr)]
75#[strum(serialize_all = "kebab-case")]
76pub enum OvpnCommand {
77    // --- Informational ---
78    /// Request connection status in the given format.
79    /// Wire: `status` / `status 2` / `status 3`
80    Status(StatusFormat),
81
82    /// Print current state (single comma-delimited line).
83    /// Wire: `state`
84    State,
85
86    /// Control real-time state notifications and/or dump history.
87    /// Wire: `state on` / `state off` / `state all` / `state on all` / `state 3`
88    StateStream(StreamMode),
89
90    /// Print the OpenVPN and management interface version.
91    /// Wire: `version`
92    Version,
93
94    /// Show the PID of the OpenVPN process.
95    /// Wire: `pid`
96    Pid,
97
98    /// List available management commands.
99    /// Wire: `help`
100    Help,
101
102    /// Get or set the log verbosity level (0–15).
103    /// `Verb(None)` queries the current level; `Verb(Some(n))` sets it.
104    /// Wire: `verb` / `verb 4`
105    Verb(Option<u8>),
106
107    /// Get or set the mute threshold (suppress repeating messages).
108    /// Wire: `mute` / `mute 40`
109    Mute(Option<u32>),
110
111    /// (Windows only) Show network adapter list and routing table.
112    /// Wire: `net`
113    Net,
114
115    // --- Real-time notification control ---
116    /// Control real-time log streaming and/or dump log history.
117    /// Wire: `log on` / `log off` / `log all` / `log on all` / `log 20`
118    Log(StreamMode),
119
120    /// Control real-time echo parameter notifications.
121    /// Wire: `echo on` / `echo off` / `echo all` / `echo on all`
122    Echo(StreamMode),
123
124    /// Enable/disable byte count notifications at N-second intervals.
125    /// Pass 0 to disable.
126    /// Wire: `bytecount 5` / `bytecount 0`
127    ByteCount(u32),
128
129    // --- Connection control ---
130    /// Send a signal to the OpenVPN daemon.
131    /// Wire: `signal SIGUSR1`
132    Signal(Signal),
133
134    /// Kill a specific client connection (server mode).
135    /// Wire: `kill Test-Client` / `kill 1.2.3.4:4000`
136    Kill(KillTarget),
137
138    /// Query the current hold flag.
139    /// Wire: `hold`
140    /// Response: `SUCCESS: hold=0` or `SUCCESS: hold=1`
141    HoldQuery,
142
143    /// Set the hold flag on — future restarts will pause until released.
144    /// Wire: `hold on`
145    HoldOn,
146
147    /// Clear the hold flag.
148    /// Wire: `hold off`
149    HoldOff,
150
151    /// Release from hold state and start OpenVPN. Does not change the
152    /// hold flag itself.
153    /// Wire: `hold release`
154    HoldRelease,
155
156    // --- Authentication ---
157    /// Supply a username for the given auth type.
158    /// Wire: `username "Auth" myuser`
159    Username {
160        /// Which credential set this username belongs to.
161        auth_type: AuthType,
162        /// The username value (redacted in debug output).
163        value: Redacted,
164    },
165
166    /// Supply a password for the given auth type. The value is escaped
167    /// and double-quoted per the OpenVPN config-file lexer rules.
168    /// Wire: `password "Private Key" "foo\"bar"`
169    Password {
170        /// Which credential set this password belongs to.
171        auth_type: AuthType,
172        /// The password value (redacted in debug output, escaped on the wire).
173        value: Redacted,
174    },
175
176    /// Set the auth-retry strategy.
177    /// Wire: `auth-retry interact`
178    AuthRetry(AuthRetryMode),
179
180    /// Forget all passwords entered during this management session.
181    /// Wire: `forget-passwords`
182    ForgetPasswords,
183
184    // --- Challenge-response authentication ---
185    /// Respond to a CRV1 dynamic challenge.
186    /// Wire: `password "Auth" "CRV1::state_id::response"`
187    ChallengeResponse {
188        /// The opaque state ID from the `>PASSWORD:` CRV1 notification.
189        state_id: String,
190        /// The user's response to the challenge (redacted in debug output).
191        response: Redacted,
192    },
193
194    /// Respond to a static challenge (SC).
195    /// Wire: `password "Auth" "SCRV1::base64_password::base64_response"`
196    ///
197    /// The caller must pre-encode password and response as base64 —
198    /// this crate does not include a base64 dependency.
199    StaticChallengeResponse {
200        /// Base64-encoded password (redacted in debug output).
201        password_b64: Redacted,
202        /// Base64-encoded challenge response (redacted in debug output).
203        response_b64: Redacted,
204    },
205
206    // --- Interactive prompts (OpenVPN 2.1+) ---
207    /// Respond to a `>NEED-OK:` prompt.
208    /// Wire: `needok token-insertion-request ok` / `needok ... cancel`
209    NeedOk {
210        /// The prompt name from the `>NEED-OK:` notification.
211        name: String,
212        /// Accept or cancel.
213        response: NeedOkResponse,
214    },
215
216    /// Respond to a `>NEED-STR:` prompt with a string value.
217    /// Wire: `needstr name "John"`
218    NeedStr {
219        /// The prompt name from the `>NEED-STR:` notification.
220        name: String,
221        /// The string value to send (will be escaped on the wire).
222        value: String,
223    },
224
225    // --- PKCS#11 (OpenVPN 2.1+) ---
226    /// Query available PKCS#11 certificate count.
227    /// Wire: `pkcs11-id-count`
228    Pkcs11IdCount,
229
230    /// Retrieve a PKCS#11 certificate by index.
231    /// Wire: `pkcs11-id-get 1`
232    Pkcs11IdGet(u32),
233
234    // --- External key / RSA signature (OpenVPN 2.3+) ---
235    /// Provide an RSA signature in response to `>RSA_SIGN:`.
236    /// This is a multi-line command: the encoder writes `rsa-sig`,
237    /// then each base64 line, then `END`.
238    RsaSig {
239        /// Base64-encoded signature lines.
240        base64_lines: Vec<String>,
241    },
242
243    // --- Client management (server mode, OpenVPN 2.1+) ---
244    /// Authorize a `>CLIENT:CONNECT` or `>CLIENT:REAUTH` and push config
245    /// directives. Multi-line command: header, config lines, `END`.
246    /// An empty `config_lines` produces a null block (header + immediate END),
247    /// which is equivalent to `client-auth-nt` in effect.
248    ClientAuth {
249        /// Client ID from the `>CLIENT:` notification.
250        cid: u64,
251        /// Key ID from the `>CLIENT:` notification.
252        kid: u64,
253        /// Config directives to push (e.g. `push "route ..."`).
254        config_lines: Vec<String>,
255    },
256
257    /// Authorize a client without pushing any config.
258    /// Wire: `client-auth-nt {CID} {KID}`
259    ClientAuthNt {
260        /// Client ID.
261        cid: u64,
262        /// Key ID.
263        kid: u64,
264    },
265
266    /// Deny a `>CLIENT:CONNECT` or `>CLIENT:REAUTH`.
267    /// Wire: `client-deny {CID} {KID} "reason" ["client-reason"]`
268    ClientDeny {
269        /// Client ID.
270        cid: u64,
271        /// Key ID.
272        kid: u64,
273        /// Server-side reason string (logged but not sent to client).
274        reason: String,
275        /// Optional message sent to the client as part of AUTH_FAILED.
276        client_reason: Option<String>,
277    },
278
279    /// Kill a client session by CID, optionally with a custom message.
280    /// Wire: `client-kill {CID}` or `client-kill {CID} {message}`
281    /// Default message is `RESTART` if omitted.
282    ClientKill {
283        /// Client ID.
284        cid: u64,
285        /// Optional kill message (e.g. `"HALT"`, `"RESTART"`). Defaults to
286        /// `RESTART` on the server if `None`.
287        message: Option<String>,
288    },
289
290    // --- Remote/Proxy override ---
291    /// Respond to a `>REMOTE:` notification (requires `--management-query-remote`).
292    /// Wire: `remote ACCEPT` / `remote SKIP` / `remote MOD host port`
293    Remote(RemoteAction),
294
295    /// Respond to a `>PROXY:` notification (requires `--management-query-proxy`).
296    /// Wire: `proxy NONE` / `proxy HTTP host port [nct]` / `proxy SOCKS host port`
297    Proxy(ProxyAction),
298
299    // --- Server statistics ---
300    /// Request aggregated server stats.
301    /// Wire: `load-stats`
302    /// Response: `SUCCESS: nclients=N,bytesin=N,bytesout=N`
303    LoadStats,
304
305    // --- Extended client management (OpenVPN 2.5+) ---
306    /// Defer authentication for a client, allowing async auth backends.
307    /// Wire: `client-pending-auth {CID} {KID} {EXTRA} {TIMEOUT}`
308    ClientPendingAuth {
309        /// Client ID.
310        cid: u64,
311        /// Key ID.
312        kid: u64,
313        /// Extra opaque string passed to the auth backend.
314        extra: String,
315        /// Timeout in seconds before the pending auth expires.
316        timeout: u32,
317    },
318
319    /// Respond to a CR_TEXT challenge (client-side, OpenVPN 2.6+).
320    /// Wire: `cr-response {base64-response}`
321    CrResponse {
322        /// The base64-encoded challenge-response answer (redacted in debug output).
323        response: Redacted,
324    },
325
326    // --- External key signature (OpenVPN 2.5+, management v2+) ---
327    /// Provide a signature in response to `>PK_SIGN:`. Replacement for
328    /// `rsa-sig` that supports ECDSA, RSA-PSS, and other key types.
329    /// Multi-line command: `pk-sig`, base64 lines, `END`.
330    PkSig {
331        /// Base64-encoded signature lines.
332        base64_lines: Vec<String>,
333    },
334
335    // --- ENV filter (OpenVPN 2.6+) ---
336    /// Set the env-var filter level for `>CLIENT:ENV` blocks.
337    /// Level 0 = all vars, higher levels filter more.
338    /// Wire: `env-filter [level]`
339    /// Response: `SUCCESS: env_filter_level=N`
340    EnvFilter(u32),
341
342    // --- Remote entry queries (management v3+) ---
343    /// Query the number of `--remote` entries configured.
344    /// Wire: `remote-entry-count`
345    /// Response: multi-line (count, then `END`).
346    RemoteEntryCount,
347
348    /// Retrieve `--remote` entries by index or all at once.
349    /// Wire: `remote-entry-get i|all [j]`
350    /// Response: multi-line (`index,remote_string` per line, then `END`).
351    RemoteEntryGet(RemoteEntryRange),
352
353    // --- Push updates (OpenVPN 2.7+, server mode) ---
354    /// Broadcast a push option update to all connected clients.
355    /// Wire: `push-update-broad "options"`
356    PushUpdateBroad {
357        /// Quoted options string (e.g. `"route 10.0.0.0, -dns"`).
358        options: String,
359    },
360
361    /// Push an option update to a specific client by CID.
362    /// Wire: `push-update-cid CID "options"`
363    PushUpdateCid {
364        /// Client ID.
365        cid: u64,
366        /// Quoted options string.
367        options: String,
368    },
369
370    // --- External certificate (OpenVPN 2.4+) ---
371    /// Supply an external certificate in response to `>NEED-CERTIFICATE`.
372    /// Multi-line command: header, PEM lines, `END`.
373    /// Wire: `certificate\n{pem_lines}\nEND`
374    Certificate {
375        /// PEM-encoded certificate lines.
376        pem_lines: Vec<String>,
377    },
378
379    // --- Management interface authentication ---
380    /// Authenticate to the management interface itself. Sent as a bare
381    /// line (no command prefix, no quoting) in response to
382    /// [`crate::OvpnMessage::PasswordPrompt`].
383    /// Wire: `{password}\n`
384    ManagementPassword(Redacted),
385
386    // --- Session lifecycle ---
387    /// Close the management session. OpenVPN keeps running and resumes
388    /// listening for new management connections.
389    Exit,
390
391    /// Identical to `Exit`.
392    Quit,
393
394    // --- Escape hatch ---
395    /// Send a raw command string for anything not yet modeled above.
396    /// The decoder expects a `SUCCESS:`/`ERROR:` response.
397    Raw(String),
398
399    /// Send a raw command string, expecting a multi-line (END-terminated)
400    /// response.
401    ///
402    /// Like [`Raw`](Self::Raw), the string is passed through the encoder's
403    /// wire-safety gate before sending (see [`crate::EncoderMode`]). Unlike
404    /// `Raw`, the decoder accumulates the response into
405    /// [`OvpnMessage::MultiLine`](crate::OvpnMessage::MultiLine).
406    RawMultiLine(String),
407}
408
409/// What kind of response the decoder should expect after a given command.
410/// This is the core of the command-tracking mechanism that resolves the
411/// protocol's ambiguity around single-line vs. multi-line responses.
412#[derive(Debug, Clone, Copy, PartialEq, Eq)]
413pub(crate) enum ResponseKind {
414    /// Expect a `SUCCESS:` or `ERROR:` line.
415    SuccessOrError,
416
417    /// Expect multiple lines terminated by a bare `END`.
418    MultiLine,
419
420    /// No response expected (connection may close).
421    NoResponse,
422}
423
424impl OvpnCommand {
425    /// Determine what kind of response this command produces, so the
426    /// decoder knows how to frame the next incoming bytes.
427    pub(crate) fn expected_response(&self) -> ResponseKind {
428        match self {
429            // These always produce multi-line (END-terminated) responses.
430            Self::Status(_)
431            | Self::Version
432            | Self::Help
433            | Self::Net
434            | Self::RemoteEntryCount
435            | Self::RemoteEntryGet(_) => ResponseKind::MultiLine,
436
437            // state/log/echo: depends on the specific sub-mode.
438            Self::StateStream(mode) | Self::Log(mode) | Self::Echo(mode) => match mode {
439                StreamMode::All | StreamMode::OnAll | StreamMode::Recent(_) => {
440                    ResponseKind::MultiLine
441                }
442                StreamMode::On | StreamMode::Off => ResponseKind::SuccessOrError,
443            },
444
445            // Bare `state` returns state history (END-terminated).
446            Self::State => ResponseKind::MultiLine,
447
448            // Raw multi-line expects END-terminated response.
449            Self::RawMultiLine(_) => ResponseKind::MultiLine,
450
451            // exit/quit close the connection.
452            Self::Exit | Self::Quit => ResponseKind::NoResponse,
453
454            // Everything else (including Raw) produces SUCCESS: or ERROR:.
455            _ => ResponseKind::SuccessOrError,
456        }
457    }
458}
459
460impl FromStr for OvpnCommand {
461    type Err = CommandParseError;
462
463    /// Parse a human-readable command string into an [`OvpnCommand`].
464    ///
465    /// This accepts the same syntax used by interactive management clients:
466    /// a command name followed by space-separated arguments.
467    ///
468    /// Commands that cannot be represented as a single line (multi-line bodies
469    /// like `rsa-sig`, `client-auth` config lines, `certificate` PEM) are
470    /// parsed with comma-separated lines in the argument position.
471    ///
472    /// Unrecognized commands fall through to [`OvpnCommand::Raw`].
473    ///
474    /// # Examples
475    ///
476    /// ```
477    /// use openvpn_mgmt_codec::OvpnCommand;
478    ///
479    /// let cmd: OvpnCommand = "version".parse().unwrap();
480    /// assert_eq!(cmd, OvpnCommand::Version);
481    ///
482    /// let cmd: OvpnCommand = "state on all".parse().unwrap();
483    /// assert_eq!(cmd, OvpnCommand::StateStream(openvpn_mgmt_codec::StreamMode::OnAll));
484    /// ```
485    fn from_str(line: &str) -> Result<Self, Self::Err> {
486        /// Shorthand for `Err(CommandParseError::Syntax(...))`.
487        fn cmd_err<T>(msg: impl Into<String>) -> Result<T, CommandParseError> {
488            Err(CommandParseError::Syntax(msg.into()))
489        }
490
491        let line = line.trim();
492        let (cmd, args) = line
493            .split_once(char::is_whitespace)
494            .map(|(c, a)| (c, a.trim()))
495            .unwrap_or((line, ""));
496
497        match cmd {
498            // --- Informational ---
499            "version" => Ok(Self::Version),
500            "pid" => Ok(Self::Pid),
501            "help" => Ok(Self::Help),
502            "net" => Ok(Self::Net),
503            "load-stats" => Ok(Self::LoadStats),
504
505            "status" => match args {
506                "" | "1" => Ok(Self::Status(StatusFormat::V1)),
507                "2" => Ok(Self::Status(StatusFormat::V2)),
508                "3" => Ok(Self::Status(StatusFormat::V3)),
509                _ => cmd_err(format!("invalid status format: {args} (use 1, 2, or 3)")),
510            },
511
512            "state" => match args {
513                "" => Ok(Self::State),
514                other => Ok(Self::StateStream(other.parse::<StreamMode>()?)),
515            },
516
517            "log" => Ok(Self::Log(args.parse::<StreamMode>()?)),
518            "echo" => Ok(Self::Echo(args.parse::<StreamMode>()?)),
519
520            "verb" => {
521                if args.is_empty() {
522                    Ok(Self::Verb(None))
523                } else {
524                    args.parse::<u8>()
525                        .map(|n| Self::Verb(Some(n)))
526                        .map_err(|_| {
527                            CommandParseError::Syntax(format!("invalid verbosity: {args} (0-15)"))
528                        })
529                }
530            }
531
532            "mute" => {
533                if args.is_empty() {
534                    Ok(Self::Mute(None))
535                } else {
536                    args.parse::<u32>()
537                        .map(|n| Self::Mute(Some(n)))
538                        .map_err(|_| {
539                            CommandParseError::Syntax(format!("invalid mute value: {args}"))
540                        })
541                }
542            }
543
544            "bytecount" => args.parse::<u32>().map(Self::ByteCount).map_err(|_| {
545                CommandParseError::Syntax(format!("bytecount requires a number, got: {args}"))
546            }),
547
548            // --- Connection control ---
549            "signal" => Ok(Self::Signal(args.parse::<Signal>()?)),
550
551            "kill" => {
552                if args.is_empty() {
553                    return cmd_err("kill requires a target (common name or proto:ip:port)");
554                }
555                let parts: Vec<&str> = args.splitn(3, ':').collect();
556                if parts.len() == 3
557                    && let Ok(port) = parts[2].parse::<u16>()
558                {
559                    return Ok(Self::Kill(KillTarget::Address {
560                        protocol: parts[0]
561                            .parse()
562                            .inspect_err(|error| warn!(%error, "unknown transport protocol"))
563                            .unwrap_or_else(|_| TransportProtocol::Unknown(parts[0].to_string())),
564                        ip: parts[1].to_string(),
565                        port,
566                    }));
567                }
568                Ok(Self::Kill(KillTarget::CommonName(args.to_string())))
569            }
570
571            "hold" => match args {
572                "" => Ok(Self::HoldQuery),
573                "on" => Ok(Self::HoldOn),
574                "off" => Ok(Self::HoldOff),
575                "release" => Ok(Self::HoldRelease),
576                _ => cmd_err(format!("invalid hold argument: {args}")),
577            },
578
579            // --- Authentication ---
580            "username" => {
581                let (auth_type, value) =
582                    args.split_once(char::is_whitespace)
583                        .ok_or(CommandParseError::Syntax(
584                            "usage: username <auth-type> <value>".into(),
585                        ))?;
586                Ok(Self::Username {
587                    auth_type: auth_type
588                        .parse()
589                        .inspect_err(|error| warn!(%error, "unknown auth type"))
590                        .unwrap_or_else(|_| AuthType::Unknown(auth_type.to_string())),
591                    value: value.trim().into(),
592                })
593            }
594
595            "password" => {
596                let (auth_type, value) =
597                    args.split_once(char::is_whitespace)
598                        .ok_or(CommandParseError::Syntax(
599                            "usage: password <auth-type> <value>".into(),
600                        ))?;
601                Ok(Self::Password {
602                    auth_type: auth_type
603                        .parse()
604                        .inspect_err(|error| warn!(%error, "unknown auth type"))
605                        .unwrap_or_else(|_| AuthType::Unknown(auth_type.to_string())),
606                    value: value.trim().into(),
607                })
608            }
609
610            "auth-retry" => Ok(Self::AuthRetry(args.parse::<AuthRetryMode>()?)),
611
612            "forget-passwords" => Ok(Self::ForgetPasswords),
613
614            // --- Interactive prompts ---
615            "needok" => {
616                let (name, resp) =
617                    args.rsplit_once(char::is_whitespace)
618                        .ok_or(CommandParseError::Syntax(
619                            "usage: needok <name> ok|cancel".into(),
620                        ))?;
621                let response = match resp {
622                    "ok" => NeedOkResponse::Ok,
623                    "cancel" => NeedOkResponse::Cancel,
624                    _ => {
625                        return cmd_err(format!("invalid needok response: {resp} (use ok/cancel)"));
626                    }
627                };
628                Ok(Self::NeedOk {
629                    name: name.trim().to_string(),
630                    response,
631                })
632            }
633
634            "needstr" => {
635                let (name, value) =
636                    args.split_once(char::is_whitespace)
637                        .ok_or(CommandParseError::Syntax(
638                            "usage: needstr <name> <value>".into(),
639                        ))?;
640                Ok(Self::NeedStr {
641                    name: name.to_string(),
642                    value: value.trim().to_string(),
643                })
644            }
645
646            // --- PKCS#11 ---
647            "pkcs11-id-count" => Ok(Self::Pkcs11IdCount),
648
649            "pkcs11-id-get" => args.parse::<u32>().map(Self::Pkcs11IdGet).map_err(|_| {
650                CommandParseError::Syntax(format!("pkcs11-id-get requires a number, got: {args}"))
651            }),
652
653            // --- Client management (server mode) ---
654            "client-auth" => {
655                let mut parts = args.splitn(3, char::is_whitespace);
656                let cid = parts
657                    .next()
658                    .ok_or(CommandParseError::Syntax(
659                        "usage: client-auth <cid> <kid> [config-lines]".into(),
660                    ))?
661                    .parse::<u64>()
662                    .map_err(|_| CommandParseError::Syntax("cid must be a number".into()))?;
663                let kid = parts
664                    .next()
665                    .ok_or(CommandParseError::Syntax(
666                        "usage: client-auth <cid> <kid> [config-lines]".into(),
667                    ))?
668                    .parse::<u64>()
669                    .map_err(|_| CommandParseError::Syntax("kid must be a number".into()))?;
670                let config_lines = match parts.next() {
671                    Some(rest) => rest.split(',').map(|s| s.trim().to_string()).collect(),
672                    None => vec![],
673                };
674                Ok(Self::ClientAuth {
675                    cid,
676                    kid,
677                    config_lines,
678                })
679            }
680
681            "client-auth-nt" => {
682                let (cid_s, kid_s) =
683                    args.split_once(char::is_whitespace)
684                        .ok_or(CommandParseError::Syntax(
685                            "usage: client-auth-nt <cid> <kid>".into(),
686                        ))?;
687                Ok(Self::ClientAuthNt {
688                    cid: cid_s
689                        .parse()
690                        .map_err(|_| CommandParseError::Syntax("cid must be a number".into()))?,
691                    kid: kid_s
692                        .trim()
693                        .parse()
694                        .map_err(|_| CommandParseError::Syntax("kid must be a number".into()))?,
695                })
696            }
697
698            "client-deny" => {
699                let mut parts = args.splitn(4, char::is_whitespace);
700                let cid = parts
701                    .next()
702                    .ok_or(CommandParseError::Syntax(
703                        "usage: client-deny <cid> <kid> <reason> [client-reason]".into(),
704                    ))?
705                    .parse::<u64>()
706                    .map_err(|_| CommandParseError::Syntax("cid must be a number".into()))?;
707                let kid = parts
708                    .next()
709                    .ok_or(CommandParseError::Syntax(
710                        "usage: client-deny <cid> <kid> <reason> [client-reason]".into(),
711                    ))?
712                    .parse::<u64>()
713                    .map_err(|_| CommandParseError::Syntax("kid must be a number".into()))?;
714                let reason = parts
715                    .next()
716                    .ok_or(CommandParseError::Syntax(
717                        "usage: client-deny <cid> <kid> <reason> [client-reason]".into(),
718                    ))?
719                    .to_string();
720                let client_reason = parts.next().map(|s| s.to_string());
721                Ok(Self::ClientDeny {
722                    cid,
723                    kid,
724                    reason,
725                    client_reason,
726                })
727            }
728
729            "client-kill" => {
730                let (cid_str, message) = match args.split_once(char::is_whitespace) {
731                    Some((c, m)) => (c, Some(m.trim().to_string())),
732                    None => (args, None),
733                };
734                let cid = cid_str.parse::<u64>().map_err(|_| {
735                    CommandParseError::Syntax(format!(
736                        "client-kill requires a CID number, got: {cid_str}"
737                    ))
738                })?;
739                Ok(Self::ClientKill { cid, message })
740            }
741
742            // --- Remote/Proxy override ---
743            "remote" => match args.split_whitespace().collect::<Vec<_>>().as_slice() {
744                ["accept" | "ACCEPT"] => Ok(Self::Remote(RemoteAction::Accept)),
745                ["skip" | "SKIP"] => Ok(Self::Remote(RemoteAction::Skip)),
746                ["mod" | "MOD", host, port] => Ok(Self::Remote(RemoteAction::Modify {
747                    host: host.to_string(),
748                    port: port
749                        .parse()
750                        .map_err(|_| CommandParseError::Syntax("port must be a number".into()))?,
751                })),
752                _ => cmd_err("usage: remote accept|skip|mod <host> <port>"),
753            },
754
755            "proxy" => match args.split_whitespace().collect::<Vec<_>>().as_slice() {
756                ["none" | "NONE"] => Ok(Self::Proxy(ProxyAction::None)),
757                ["http" | "HTTP", host, port] => Ok(Self::Proxy(ProxyAction::Http {
758                    host: host.to_string(),
759                    port: port
760                        .parse()
761                        .map_err(|_| CommandParseError::Syntax("port must be a number".into()))?,
762                    non_cleartext_only: false,
763                })),
764                ["http" | "HTTP", host, port, "nct"] => Ok(Self::Proxy(ProxyAction::Http {
765                    host: host.to_string(),
766                    port: port
767                        .parse()
768                        .map_err(|_| CommandParseError::Syntax("port must be a number".into()))?,
769                    non_cleartext_only: true,
770                })),
771                ["socks" | "SOCKS", host, port] => Ok(Self::Proxy(ProxyAction::Socks {
772                    host: host.to_string(),
773                    port: port
774                        .parse()
775                        .map_err(|_| CommandParseError::Syntax("port must be a number".into()))?,
776                })),
777                _ => cmd_err("usage: proxy none|http <host> <port> [nct]|socks <host> <port>"),
778            },
779
780            // --- ENV filter ---
781            "env-filter" => {
782                let level = if args.is_empty() {
783                    0
784                } else {
785                    args.parse::<u32>().map_err(|_| {
786                        CommandParseError::Syntax(format!("invalid env-filter level: {args}"))
787                    })?
788                };
789                Ok(Self::EnvFilter(level))
790            }
791
792            // --- Remote entry queries ---
793            "remote-entry-count" => Ok(Self::RemoteEntryCount),
794
795            "remote-entry-get" => {
796                if args.is_empty() {
797                    return cmd_err("usage: remote-entry-get i|all [j]");
798                }
799                let range = if args == "all" {
800                    RemoteEntryRange::All
801                } else {
802                    let mut parts = args.splitn(2, char::is_whitespace);
803                    let from = parts.next().unwrap().parse::<u32>().map_err(|_| {
804                        CommandParseError::Syntax(format!(
805                            "remote-entry-get index must be a number or 'all', got: {args}"
806                        ))
807                    })?;
808                    match parts.next() {
809                        Some(to_str) => {
810                            let to = to_str.trim().parse::<u32>().map_err(|_| {
811                                CommandParseError::Syntax(format!(
812                                    "remote-entry-get end index must be a number, got: {to_str}"
813                                ))
814                            })?;
815                            RemoteEntryRange::Range { from, to }
816                        }
817                        None => RemoteEntryRange::Single(from),
818                    }
819                };
820                Ok(Self::RemoteEntryGet(range))
821            }
822
823            // --- Push updates ---
824            "push-update-broad" => {
825                if args.is_empty() {
826                    return cmd_err("usage: push-update-broad <options>");
827                }
828                Ok(Self::PushUpdateBroad {
829                    options: args.to_string(),
830                })
831            }
832
833            "push-update-cid" => {
834                let (cid_str, options) =
835                    args.split_once(char::is_whitespace)
836                        .ok_or(CommandParseError::Syntax(
837                            "usage: push-update-cid <cid> <options>".into(),
838                        ))?;
839                let cid = cid_str.parse::<u64>().map_err(|_| {
840                    CommandParseError::Syntax("push-update-cid: cid must be a number".into())
841                })?;
842                Ok(Self::PushUpdateCid {
843                    cid,
844                    options: options.trim().to_string(),
845                })
846            }
847
848            // --- Raw multi-line ---
849            "raw-ml" => {
850                if args.is_empty() {
851                    return cmd_err("usage: raw-ml <command>");
852                }
853                Ok(Self::RawMultiLine(args.to_string()))
854            }
855
856            // --- Lifecycle ---
857            "exit" => Ok(Self::Exit),
858            "quit" => Ok(Self::Quit),
859
860            // --- Fallback: send as raw command ---
861            _ => Ok(Self::Raw(line.to_string())),
862        }
863    }
864}
865
866/// The standard startup sequence that most management clients send after
867/// connecting.
868///
869/// This is the pattern used by `node-openvpn` and other clients: enable
870/// log streaming, request the PID, start byte-count notifications, and
871/// release the hold so OpenVPN begins connecting.
872///
873/// # Arguments
874///
875/// * `bytecount_interval` — seconds between `>BYTECOUNT:` notifications
876///   (pass `0` to skip enabling byte counts).
877///
878/// # Examples
879///
880/// ```
881/// use openvpn_mgmt_codec::command::connection_sequence;
882/// use openvpn_mgmt_codec::OvpnCommand;
883///
884/// let cmds = connection_sequence(5);
885/// assert!(cmds.iter().any(|c| matches!(c, OvpnCommand::HoldRelease)));
886/// ```
887///
888/// To send these over a framed connection:
889///
890/// ```no_run
891/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
892/// use tokio::net::TcpStream;
893/// use tokio_util::codec::Framed;
894/// use futures::SinkExt;
895/// use openvpn_mgmt_codec::{OvpnCodec, OvpnCommand};
896/// use openvpn_mgmt_codec::command::connection_sequence;
897///
898/// let stream = TcpStream::connect("127.0.0.1:7505").await?;
899/// let mut framed = Framed::new(stream, OvpnCodec::new());
900///
901/// for cmd in connection_sequence(5) {
902///     framed.send(cmd).await?;
903/// }
904/// # Ok(())
905/// # }
906/// ```
907pub fn connection_sequence(bytecount_interval: u32) -> Vec<OvpnCommand> {
908    let mut cmds = vec![
909        OvpnCommand::Log(StreamMode::OnAll),
910        OvpnCommand::StateStream(StreamMode::OnAll),
911        OvpnCommand::Pid,
912    ];
913    if bytecount_interval > 0 {
914        cmds.push(OvpnCommand::ByteCount(bytecount_interval));
915    }
916    cmds.push(OvpnCommand::HoldRelease);
917    cmds
918}
919
920#[cfg(test)]
921mod tests {
922    use super::*;
923
924    #[test]
925    fn into_static_str_labels() {
926        let label: &str = (&OvpnCommand::State).into();
927        assert_eq!(label, "state");
928
929        let label: &str = (&OvpnCommand::ForgetPasswords).into();
930        assert_eq!(label, "forget-passwords");
931
932        let label: &str = (&OvpnCommand::ByteCount(5)).into();
933        assert_eq!(label, "byte-count");
934    }
935
936    // --- connection_sequence ---
937
938    #[test]
939    fn connection_sequence_with_bytecount() {
940        let cmds = connection_sequence(5);
941        assert_eq!(
942            cmds,
943            vec![
944                OvpnCommand::Log(StreamMode::OnAll),
945                OvpnCommand::StateStream(StreamMode::OnAll),
946                OvpnCommand::Pid,
947                OvpnCommand::ByteCount(5),
948                OvpnCommand::HoldRelease,
949            ]
950        );
951    }
952
953    #[test]
954    fn connection_sequence_without_bytecount() {
955        let cmds = connection_sequence(0);
956        assert_eq!(
957            cmds,
958            vec![
959                OvpnCommand::Log(StreamMode::OnAll),
960                OvpnCommand::StateStream(StreamMode::OnAll),
961                OvpnCommand::Pid,
962                OvpnCommand::HoldRelease,
963            ]
964        );
965    }
966
967    // --- FromStr: informational commands ---
968
969    #[test]
970    fn parse_simple_commands() {
971        assert_eq!("version".parse(), Ok(OvpnCommand::Version));
972        assert_eq!("pid".parse(), Ok(OvpnCommand::Pid));
973        assert_eq!("help".parse(), Ok(OvpnCommand::Help));
974        assert_eq!("net".parse(), Ok(OvpnCommand::Net));
975        assert_eq!("load-stats".parse(), Ok(OvpnCommand::LoadStats));
976        assert_eq!("forget-passwords".parse(), Ok(OvpnCommand::ForgetPasswords));
977        assert_eq!("pkcs11-id-count".parse(), Ok(OvpnCommand::Pkcs11IdCount));
978        assert_eq!("exit".parse(), Ok(OvpnCommand::Exit));
979        assert_eq!("quit".parse(), Ok(OvpnCommand::Quit));
980    }
981
982    #[test]
983    fn parse_status() {
984        assert_eq!("status".parse(), Ok(OvpnCommand::Status(StatusFormat::V1)));
985        assert_eq!(
986            "status 1".parse(),
987            Ok(OvpnCommand::Status(StatusFormat::V1))
988        );
989        assert_eq!(
990            "status 2".parse(),
991            Ok(OvpnCommand::Status(StatusFormat::V2))
992        );
993        assert_eq!(
994            "status 3".parse(),
995            Ok(OvpnCommand::Status(StatusFormat::V3))
996        );
997        assert!("status 4".parse::<OvpnCommand>().is_err());
998    }
999
1000    // --- FromStr: state / log / echo stream modes ---
1001
1002    #[test]
1003    fn parse_state_bare() {
1004        assert_eq!("state".parse(), Ok(OvpnCommand::State));
1005    }
1006
1007    #[test]
1008    fn parse_state_stream_modes() {
1009        assert_eq!(
1010            "state on".parse(),
1011            Ok(OvpnCommand::StateStream(StreamMode::On))
1012        );
1013        assert_eq!(
1014            "state off".parse(),
1015            Ok(OvpnCommand::StateStream(StreamMode::Off))
1016        );
1017        assert_eq!(
1018            "state all".parse(),
1019            Ok(OvpnCommand::StateStream(StreamMode::All))
1020        );
1021        assert_eq!(
1022            "state on all".parse(),
1023            Ok(OvpnCommand::StateStream(StreamMode::OnAll))
1024        );
1025        assert_eq!(
1026            "state 5".parse(),
1027            Ok(OvpnCommand::StateStream(StreamMode::Recent(5)))
1028        );
1029    }
1030
1031    #[test]
1032    fn parse_log_and_echo() {
1033        assert_eq!("log on".parse(), Ok(OvpnCommand::Log(StreamMode::On)));
1034        assert_eq!(
1035            "log on all".parse(),
1036            Ok(OvpnCommand::Log(StreamMode::OnAll))
1037        );
1038        assert_eq!("echo off".parse(), Ok(OvpnCommand::Echo(StreamMode::Off)));
1039        assert_eq!(
1040            "echo 10".parse(),
1041            Ok(OvpnCommand::Echo(StreamMode::Recent(10)))
1042        );
1043    }
1044
1045    // --- FromStr: verb / mute / bytecount ---
1046
1047    #[test]
1048    fn parse_verb() {
1049        assert_eq!("verb".parse(), Ok(OvpnCommand::Verb(None)));
1050        assert_eq!("verb 4".parse(), Ok(OvpnCommand::Verb(Some(4))));
1051        assert!("verb abc".parse::<OvpnCommand>().is_err());
1052    }
1053
1054    #[test]
1055    fn parse_mute() {
1056        assert_eq!("mute".parse(), Ok(OvpnCommand::Mute(None)));
1057        assert_eq!("mute 40".parse(), Ok(OvpnCommand::Mute(Some(40))));
1058        assert!("mute abc".parse::<OvpnCommand>().is_err());
1059    }
1060
1061    #[test]
1062    fn parse_bytecount() {
1063        assert_eq!("bytecount 5".parse(), Ok(OvpnCommand::ByteCount(5)));
1064        assert_eq!("bytecount 0".parse(), Ok(OvpnCommand::ByteCount(0)));
1065        assert!("bytecount".parse::<OvpnCommand>().is_err());
1066    }
1067
1068    // --- FromStr: signal ---
1069
1070    #[test]
1071    fn parse_signal() {
1072        assert_eq!(
1073            "signal SIGHUP".parse(),
1074            Ok(OvpnCommand::Signal(Signal::SigHup))
1075        );
1076        assert_eq!(
1077            "signal SIGTERM".parse(),
1078            Ok(OvpnCommand::Signal(Signal::SigTerm))
1079        );
1080        assert_eq!(
1081            "signal SIGUSR1".parse(),
1082            Ok(OvpnCommand::Signal(Signal::SigUsr1))
1083        );
1084        assert_eq!(
1085            "signal SIGUSR2".parse(),
1086            Ok(OvpnCommand::Signal(Signal::SigUsr2))
1087        );
1088        assert!("signal SIGKILL".parse::<OvpnCommand>().is_err());
1089    }
1090
1091    // --- FromStr: kill ---
1092
1093    #[test]
1094    fn parse_kill_common_name() {
1095        assert_eq!(
1096            "kill TestClient".parse(),
1097            Ok(OvpnCommand::Kill(KillTarget::CommonName(
1098                "TestClient".to_string()
1099            )))
1100        );
1101    }
1102
1103    #[test]
1104    fn parse_kill_address() {
1105        assert_eq!(
1106            "kill tcp:1.2.3.4:4000".parse(),
1107            Ok(OvpnCommand::Kill(KillTarget::Address {
1108                protocol: TransportProtocol::Tcp,
1109                ip: "1.2.3.4".to_string(),
1110                port: 4000,
1111            }))
1112        );
1113    }
1114
1115    #[test]
1116    fn parse_kill_empty_is_err() {
1117        assert!("kill".parse::<OvpnCommand>().is_err());
1118    }
1119
1120    // --- FromStr: hold ---
1121
1122    #[test]
1123    fn parse_hold() {
1124        assert_eq!("hold".parse(), Ok(OvpnCommand::HoldQuery));
1125        assert_eq!("hold on".parse(), Ok(OvpnCommand::HoldOn));
1126        assert_eq!("hold off".parse(), Ok(OvpnCommand::HoldOff));
1127        assert_eq!("hold release".parse(), Ok(OvpnCommand::HoldRelease));
1128        assert!("hold bogus".parse::<OvpnCommand>().is_err());
1129    }
1130
1131    // --- FromStr: authentication ---
1132
1133    #[test]
1134    fn parse_username() {
1135        let cmd: OvpnCommand = "username Auth alice".parse().unwrap();
1136        assert_eq!(
1137            cmd,
1138            OvpnCommand::Username {
1139                auth_type: AuthType::Auth,
1140                value: "alice".into(),
1141            }
1142        );
1143    }
1144
1145    #[test]
1146    fn parse_password() {
1147        let cmd: OvpnCommand = "password PrivateKey s3cret".parse().unwrap();
1148        assert_eq!(
1149            cmd,
1150            OvpnCommand::Password {
1151                auth_type: AuthType::PrivateKey,
1152                value: "s3cret".into(),
1153            }
1154        );
1155    }
1156
1157    #[test]
1158    fn parse_username_missing_value_is_err() {
1159        assert!("username".parse::<OvpnCommand>().is_err());
1160        assert!("username Auth".parse::<OvpnCommand>().is_err());
1161    }
1162
1163    #[test]
1164    fn parse_auth_retry() {
1165        assert_eq!(
1166            "auth-retry none".parse(),
1167            Ok(OvpnCommand::AuthRetry(AuthRetryMode::None))
1168        );
1169        assert_eq!(
1170            "auth-retry interact".parse(),
1171            Ok(OvpnCommand::AuthRetry(AuthRetryMode::Interact))
1172        );
1173        assert_eq!(
1174            "auth-retry nointeract".parse(),
1175            Ok(OvpnCommand::AuthRetry(AuthRetryMode::NoInteract))
1176        );
1177        assert!("auth-retry bogus".parse::<OvpnCommand>().is_err());
1178    }
1179
1180    // --- FromStr: interactive prompts ---
1181
1182    #[test]
1183    fn parse_needok() {
1184        assert_eq!(
1185            "needok token-insertion ok".parse(),
1186            Ok(OvpnCommand::NeedOk {
1187                name: "token-insertion".to_string(),
1188                response: NeedOkResponse::Ok,
1189            })
1190        );
1191        assert_eq!(
1192            "needok token-insertion cancel".parse(),
1193            Ok(OvpnCommand::NeedOk {
1194                name: "token-insertion".to_string(),
1195                response: NeedOkResponse::Cancel,
1196            })
1197        );
1198        assert!("needok".parse::<OvpnCommand>().is_err());
1199        assert!("needok name bogus".parse::<OvpnCommand>().is_err());
1200    }
1201
1202    #[test]
1203    fn parse_needstr() {
1204        assert_eq!(
1205            "needstr prompt-name John".parse(),
1206            Ok(OvpnCommand::NeedStr {
1207                name: "prompt-name".to_string(),
1208                value: "John".to_string(),
1209            })
1210        );
1211        assert!("needstr".parse::<OvpnCommand>().is_err());
1212    }
1213
1214    // --- FromStr: PKCS#11 ---
1215
1216    #[test]
1217    fn parse_pkcs11_id_get() {
1218        assert_eq!("pkcs11-id-get 1".parse(), Ok(OvpnCommand::Pkcs11IdGet(1)));
1219        assert!("pkcs11-id-get abc".parse::<OvpnCommand>().is_err());
1220    }
1221
1222    // --- FromStr: client management ---
1223
1224    #[test]
1225    fn parse_client_auth() {
1226        assert_eq!(
1227            "client-auth 42 7".parse(),
1228            Ok(OvpnCommand::ClientAuth {
1229                cid: 42,
1230                kid: 7,
1231                config_lines: vec![],
1232            })
1233        );
1234    }
1235
1236    #[test]
1237    fn parse_client_auth_with_config() {
1238        let cmd: OvpnCommand = "client-auth 1 2 push route 10.0.0.0,ifconfig-push 10.0.1.1"
1239            .parse()
1240            .unwrap();
1241        assert_eq!(
1242            cmd,
1243            OvpnCommand::ClientAuth {
1244                cid: 1,
1245                kid: 2,
1246                config_lines: vec![
1247                    "push route 10.0.0.0".to_string(),
1248                    "ifconfig-push 10.0.1.1".to_string(),
1249                ],
1250            }
1251        );
1252    }
1253
1254    #[test]
1255    fn parse_client_auth_nt() {
1256        assert_eq!(
1257            "client-auth-nt 5 3".parse(),
1258            Ok(OvpnCommand::ClientAuthNt { cid: 5, kid: 3 })
1259        );
1260        assert!("client-auth-nt abc 3".parse::<OvpnCommand>().is_err());
1261    }
1262
1263    #[test]
1264    fn parse_client_deny() {
1265        assert_eq!(
1266            "client-deny 1 2 rejected".parse(),
1267            Ok(OvpnCommand::ClientDeny {
1268                cid: 1,
1269                kid: 2,
1270                reason: "rejected".to_string(),
1271                client_reason: None,
1272            })
1273        );
1274        assert_eq!(
1275            "client-deny 1 2 rejected sorry".parse(),
1276            Ok(OvpnCommand::ClientDeny {
1277                cid: 1,
1278                kid: 2,
1279                reason: "rejected".to_string(),
1280                client_reason: Some("sorry".to_string()),
1281            })
1282        );
1283    }
1284
1285    #[test]
1286    fn parse_client_kill() {
1287        assert_eq!(
1288            "client-kill 99".parse(),
1289            Ok(OvpnCommand::ClientKill {
1290                cid: 99,
1291                message: None,
1292            })
1293        );
1294        assert_eq!(
1295            "client-kill 99 HALT".parse(),
1296            Ok(OvpnCommand::ClientKill {
1297                cid: 99,
1298                message: Some("HALT".to_string()),
1299            })
1300        );
1301        assert!("client-kill abc".parse::<OvpnCommand>().is_err());
1302    }
1303
1304    // --- FromStr: remote / proxy ---
1305
1306    #[test]
1307    fn parse_remote() {
1308        assert_eq!(
1309            "remote accept".parse(),
1310            Ok(OvpnCommand::Remote(RemoteAction::Accept))
1311        );
1312        assert_eq!(
1313            "remote SKIP".parse(),
1314            Ok(OvpnCommand::Remote(RemoteAction::Skip))
1315        );
1316        assert_eq!(
1317            "remote MOD example.com 443".parse(),
1318            Ok(OvpnCommand::Remote(RemoteAction::Modify {
1319                host: "example.com".to_string(),
1320                port: 443,
1321            }))
1322        );
1323        assert!("remote".parse::<OvpnCommand>().is_err());
1324    }
1325
1326    #[test]
1327    fn parse_proxy() {
1328        assert_eq!(
1329            "proxy none".parse(),
1330            Ok(OvpnCommand::Proxy(ProxyAction::None))
1331        );
1332        assert_eq!(
1333            "proxy HTTP proxy.local 8080".parse(),
1334            Ok(OvpnCommand::Proxy(ProxyAction::Http {
1335                host: "proxy.local".to_string(),
1336                port: 8080,
1337                non_cleartext_only: false,
1338            }))
1339        );
1340        assert_eq!(
1341            "proxy http proxy.local 8080 nct".parse(),
1342            Ok(OvpnCommand::Proxy(ProxyAction::Http {
1343                host: "proxy.local".to_string(),
1344                port: 8080,
1345                non_cleartext_only: true,
1346            }))
1347        );
1348        assert_eq!(
1349            "proxy socks socks.local 1080".parse(),
1350            Ok(OvpnCommand::Proxy(ProxyAction::Socks {
1351                host: "socks.local".to_string(),
1352                port: 1080,
1353            }))
1354        );
1355        assert!("proxy".parse::<OvpnCommand>().is_err());
1356    }
1357
1358    // --- FromStr: raw / raw-ml / fallback ---
1359
1360    #[test]
1361    fn parse_raw_ml() {
1362        assert_eq!(
1363            "raw-ml some-cmd".parse(),
1364            Ok(OvpnCommand::RawMultiLine("some-cmd".to_string()))
1365        );
1366        assert!("raw-ml".parse::<OvpnCommand>().is_err());
1367    }
1368
1369    #[test]
1370    fn parse_unrecognized_falls_through_to_raw() {
1371        assert_eq!(
1372            "unknown-cmd foo bar".parse(),
1373            Ok(OvpnCommand::Raw("unknown-cmd foo bar".to_string()))
1374        );
1375    }
1376
1377    #[test]
1378    fn parse_trims_whitespace() {
1379        assert_eq!("  version  ".parse(), Ok(OvpnCommand::Version));
1380        assert_eq!(
1381            "  state  on  ".parse(),
1382            Ok(OvpnCommand::StateStream(StreamMode::On))
1383        );
1384    }
1385
1386    // --- FromStr: error paths ---
1387
1388    #[test]
1389    fn parse_state_invalid_stream_mode() {
1390        assert!("state bogus".parse::<OvpnCommand>().is_err());
1391    }
1392
1393    #[test]
1394    fn parse_log_invalid_stream_mode() {
1395        assert!("log bogus".parse::<OvpnCommand>().is_err());
1396    }
1397
1398    #[test]
1399    fn parse_echo_invalid_stream_mode() {
1400        assert!("echo bogus".parse::<OvpnCommand>().is_err());
1401    }
1402
1403    #[test]
1404    fn parse_kill_unknown_protocol_falls_back() {
1405        let cmd: OvpnCommand = "kill sctp:1.2.3.4:4000".parse().unwrap();
1406        assert_eq!(
1407            cmd,
1408            OvpnCommand::Kill(KillTarget::Address {
1409                protocol: TransportProtocol::Unknown("sctp".to_string()),
1410                ip: "1.2.3.4".to_string(),
1411                port: 4000,
1412            })
1413        );
1414    }
1415
1416    #[test]
1417    fn parse_username_unknown_auth_type_falls_back() {
1418        let cmd: OvpnCommand = "username MyPlugin alice".parse().unwrap();
1419        assert_eq!(
1420            cmd,
1421            OvpnCommand::Username {
1422                auth_type: AuthType::Unknown("MyPlugin".to_string()),
1423                value: "alice".into(),
1424            }
1425        );
1426    }
1427
1428    #[test]
1429    fn parse_password_unknown_auth_type_falls_back() {
1430        let cmd: OvpnCommand = "password MyPlugin s3cret".parse().unwrap();
1431        assert_eq!(
1432            cmd,
1433            OvpnCommand::Password {
1434                auth_type: AuthType::Unknown("MyPlugin".to_string()),
1435                value: "s3cret".into(),
1436            }
1437        );
1438    }
1439
1440    #[test]
1441    fn parse_password_missing_value_is_err() {
1442        assert!("password".parse::<OvpnCommand>().is_err());
1443        assert!("password Auth".parse::<OvpnCommand>().is_err());
1444    }
1445
1446    #[test]
1447    fn parse_client_auth_non_numeric_cid() {
1448        assert!("client-auth abc 1".parse::<OvpnCommand>().is_err());
1449    }
1450
1451    #[test]
1452    fn parse_client_auth_non_numeric_kid() {
1453        assert!("client-auth 1 abc".parse::<OvpnCommand>().is_err());
1454    }
1455
1456    #[test]
1457    fn parse_client_auth_nt_non_numeric_kid() {
1458        assert!("client-auth-nt 1 abc".parse::<OvpnCommand>().is_err());
1459    }
1460
1461    #[test]
1462    fn parse_client_deny_missing_args() {
1463        assert!("client-deny".parse::<OvpnCommand>().is_err());
1464        assert!("client-deny 1".parse::<OvpnCommand>().is_err());
1465        assert!("client-deny 1 2".parse::<OvpnCommand>().is_err());
1466    }
1467
1468    #[test]
1469    fn parse_client_deny_non_numeric_ids() {
1470        assert!("client-deny abc 1 reason".parse::<OvpnCommand>().is_err());
1471        assert!("client-deny 1 abc reason".parse::<OvpnCommand>().is_err());
1472    }
1473
1474    #[test]
1475    fn parse_remote_non_numeric_port() {
1476        assert!("remote mod host abc".parse::<OvpnCommand>().is_err());
1477    }
1478
1479    #[test]
1480    fn parse_proxy_non_numeric_port() {
1481        assert!("proxy http host abc".parse::<OvpnCommand>().is_err());
1482        assert!("proxy http host abc nct".parse::<OvpnCommand>().is_err());
1483        assert!("proxy socks host abc".parse::<OvpnCommand>().is_err());
1484    }
1485
1486    #[test]
1487    fn parse_pkcs11_id_get_missing_arg() {
1488        assert!("pkcs11-id-get".parse::<OvpnCommand>().is_err());
1489    }
1490
1491    #[test]
1492    fn parse_bytecount_non_numeric() {
1493        assert!("bytecount abc".parse::<OvpnCommand>().is_err());
1494    }
1495
1496    #[test]
1497    fn parse_needstr_missing_value() {
1498        assert!("needstr".parse::<OvpnCommand>().is_err());
1499    }
1500
1501    // --- FromStr: new commands ---
1502
1503    #[test]
1504    fn parse_env_filter() {
1505        assert_eq!("env-filter 2".parse(), Ok(OvpnCommand::EnvFilter(2)));
1506        assert_eq!("env-filter 0".parse(), Ok(OvpnCommand::EnvFilter(0)));
1507        assert_eq!("env-filter".parse(), Ok(OvpnCommand::EnvFilter(0)));
1508        assert!("env-filter abc".parse::<OvpnCommand>().is_err());
1509    }
1510
1511    #[test]
1512    fn parse_remote_entry_count() {
1513        assert_eq!(
1514            "remote-entry-count".parse(),
1515            Ok(OvpnCommand::RemoteEntryCount)
1516        );
1517    }
1518
1519    #[test]
1520    fn parse_remote_entry_get() {
1521        assert_eq!(
1522            "remote-entry-get 0".parse(),
1523            Ok(OvpnCommand::RemoteEntryGet(RemoteEntryRange::Single(0)))
1524        );
1525        assert_eq!(
1526            "remote-entry-get 0 3".parse(),
1527            Ok(OvpnCommand::RemoteEntryGet(RemoteEntryRange::Range {
1528                from: 0,
1529                to: 3
1530            }))
1531        );
1532        assert_eq!(
1533            "remote-entry-get all".parse(),
1534            Ok(OvpnCommand::RemoteEntryGet(RemoteEntryRange::All))
1535        );
1536        assert!("remote-entry-get".parse::<OvpnCommand>().is_err());
1537        assert!("remote-entry-get abc".parse::<OvpnCommand>().is_err());
1538        assert!("remote-entry-get 0 abc".parse::<OvpnCommand>().is_err());
1539    }
1540
1541    #[test]
1542    fn parse_push_update_broad() {
1543        let cmd: OvpnCommand = "push-update-broad route 10.0.0.0".parse().unwrap();
1544        assert_eq!(
1545            cmd,
1546            OvpnCommand::PushUpdateBroad {
1547                options: "route 10.0.0.0".to_string()
1548            }
1549        );
1550        assert!("push-update-broad".parse::<OvpnCommand>().is_err());
1551    }
1552
1553    #[test]
1554    fn parse_push_update_cid() {
1555        let cmd: OvpnCommand = "push-update-cid 42 route 10.0.0.0".parse().unwrap();
1556        assert_eq!(
1557            cmd,
1558            OvpnCommand::PushUpdateCid {
1559                cid: 42,
1560                options: "route 10.0.0.0".to_string()
1561            }
1562        );
1563        assert!("push-update-cid".parse::<OvpnCommand>().is_err());
1564        assert!("push-update-cid abc opts".parse::<OvpnCommand>().is_err());
1565    }
1566}