Skip to main content

shell_tunnel/
cli.rs

1//! Command-line interface for shell-tunnel.
2//!
3//! Uses lexopt for minimal binary size overhead (~34KB).
4
5use std::ffi::OsString;
6use std::net::IpAddr;
7use std::path::PathBuf;
8
9/// Command-line arguments.
10#[derive(Debug, Clone)]
11pub struct Args {
12    /// Host address to bind to.
13    pub host: IpAddr,
14    /// Port to listen on.
15    pub port: u16,
16    /// Whether the port was stated rather than defaulted.
17    ///
18    /// A relay-attached device serves only itself on loopback, so the port is an
19    /// implementation detail there — but only if the user did not ask for one.
20    pub port_explicit: bool,
21    /// Path to configuration file.
22    pub config: Option<PathBuf>,
23    /// API key for authentication (overrides config file).
24    pub api_key: Option<String>,
25    /// Disable authentication.
26    pub no_auth: bool,
27    /// Require authentication, auto-generating an API key if none is provided.
28    pub require_auth: bool,
29    /// Capability strings scoping the issued token(s) (empty = full-control).
30    pub capabilities: Vec<String>,
31    /// Role preset scoping the issued token(s) (operator/read-only/full-control).
32    pub preset: Option<String>,
33    /// Disable rate limiting.
34    pub no_rate_limit: bool,
35    /// Expose the server through a Cloudflare quick tunnel.
36    pub tunnel: bool,
37    /// Expose the server through an arbitrary tunnel command.
38    pub tunnel_command: Option<String>,
39    /// Run as a relay server (`shell-tunnel relay`) instead of a shell gateway.
40    pub relay: bool,
41    /// Attach to this relay instead of publishing through a tunnel.
42    pub relay_url: Option<String>,
43    /// Shared secret devices present to attach to this relay.
44    pub enroll_token: Option<String>,
45    /// Public base URL this relay is reachable at.
46    pub public_base: Option<String>,
47    /// Stable name to claim on the relay (keeps one URL across reconnects).
48    pub device_name: Option<String>,
49    /// PEM certificate chain for serving HTTPS directly.
50    pub tls_cert: Option<PathBuf>,
51    /// PEM private key matching `tls_cert`.
52    pub tls_key: Option<PathBuf>,
53    /// Generate a self-signed certificate when none is present.
54    pub tls_self_signed: bool,
55    /// Expect exactly this certificate fingerprint from the relay.
56    pub relay_fingerprint: Option<String>,
57    /// Extra PEM certificate authority to trust when dialling a relay.
58    pub relay_ca: Option<PathBuf>,
59    /// Additional host names this server answers to.
60    pub allow_hosts: Vec<String>,
61    /// Append an audit trail of executions and refusals to this file.
62    pub audit_log: Option<PathBuf>,
63    /// Directory the filesystem API is confined to. `None` disables the API.
64    pub fs_root: Option<PathBuf>,
65    /// Chunk size advertised to upload clients, in bytes.
66    pub fs_chunk_size: Option<usize>,
67    /// Rotate the audit trail once it passes this many bytes.
68    pub audit_max_bytes: Option<u64>,
69    /// Allow any CORS origin (permissive; opt-in for browser UIs).
70    pub cors_allow_any: bool,
71    /// Log level (error, warn, info, debug, trace).
72    pub log_level: Option<String>,
73    /// Show version and exit.
74    pub version: bool,
75    /// Show help and exit.
76    pub help: bool,
77    /// Check for updates and exit.
78    pub check_update: bool,
79    /// Perform self-update and exit.
80    pub update: bool,
81    /// Disable automatic update check on startup.
82    pub no_update_check: bool,
83}
84
85impl Default for Args {
86    fn default() -> Self {
87        Self {
88            host: "127.0.0.1".parse().unwrap(),
89            port: 3000,
90            port_explicit: false,
91            config: None,
92            api_key: None,
93            no_auth: false,
94            require_auth: false,
95            capabilities: Vec::new(),
96            preset: None,
97            no_rate_limit: false,
98            tunnel: false,
99            tunnel_command: None,
100            relay: false,
101            relay_url: None,
102            enroll_token: None,
103            public_base: None,
104            device_name: None,
105            tls_cert: None,
106            tls_key: None,
107            tls_self_signed: false,
108            relay_fingerprint: None,
109            relay_ca: None,
110            allow_hosts: Vec::new(),
111            audit_log: None,
112            audit_max_bytes: None,
113            fs_root: None,
114            fs_chunk_size: None,
115            cors_allow_any: false,
116            log_level: None,
117            version: false,
118            help: false,
119            check_update: false,
120            update: false,
121            no_update_check: false,
122        }
123    }
124}
125
126/// Parse command-line arguments.
127pub fn parse_args() -> Result<Args, ArgsError> {
128    parse_args_from(std::env::args_os())
129}
130
131/// Parse arguments from an iterator (for testing).
132pub fn parse_args_from<I>(args: I) -> Result<Args, ArgsError>
133where
134    I: IntoIterator<Item = OsString>,
135{
136    use lexopt::prelude::*;
137
138    let mut result = Args::default();
139    let mut parser = lexopt::Parser::from_iter(args);
140
141    while let Some(arg) = parser.next()? {
142        match arg {
143            Short('h') | Long("help") => {
144                result.help = true;
145            }
146            Short('V') | Long("version") => {
147                result.version = true;
148            }
149            Short('H') | Long("host") => {
150                let value: String = parser.value()?.parse()?;
151                result.host = value
152                    .parse()
153                    .map_err(|_| ArgsError::InvalidValue("host", value))?;
154            }
155            Short('p') | Long("port") => {
156                let value: String = parser.value()?.parse()?;
157                result.port = value
158                    .parse()
159                    .map_err(|_| ArgsError::InvalidValue("port", value))?;
160                result.port_explicit = true;
161            }
162            Short('c') | Long("config") => {
163                result.config = Some(parser.value()?.parse()?);
164            }
165            Short('k') | Long("api-key") => {
166                result.api_key = Some(parser.value()?.parse()?);
167            }
168            Long("no-auth") => {
169                result.no_auth = true;
170            }
171            Long("require-auth") => {
172                result.require_auth = true;
173            }
174            Long("capabilities") => {
175                // Comma-separated; may be repeated. Accumulate non-empty entries.
176                let value: String = parser.value()?.parse()?;
177                result.capabilities.extend(
178                    value
179                        .split(',')
180                        .map(|s| s.trim())
181                        .filter(|s| !s.is_empty())
182                        .map(String::from),
183                );
184            }
185            Long("preset") => {
186                result.preset = Some(parser.value()?.parse()?);
187            }
188            Long("no-rate-limit") => {
189                result.no_rate_limit = true;
190            }
191            Long("tunnel") => {
192                result.tunnel = true;
193            }
194            Long("tunnel-command") => {
195                result.tunnel_command = Some(parser.value()?.parse()?);
196            }
197            Long("relay") => {
198                result.relay_url = Some(parser.value()?.parse()?);
199            }
200            Long("enroll-token") => {
201                result.enroll_token = Some(parser.value()?.parse()?);
202            }
203            Long("public-base") => {
204                result.public_base = Some(parser.value()?.parse()?);
205            }
206            Long("device-name") => {
207                result.device_name = Some(parser.value()?.parse()?);
208            }
209            Long("tls-cert") => {
210                result.tls_cert = Some(parser.value()?.parse()?);
211            }
212            Long("tls-key") => {
213                result.tls_key = Some(parser.value()?.parse()?);
214            }
215            Long("tls-self-signed") => {
216                result.tls_self_signed = true;
217            }
218            Long("relay-fingerprint") => {
219                result.relay_fingerprint = Some(parser.value()?.parse()?);
220            }
221            Long("relay-ca") => {
222                result.relay_ca = Some(parser.value()?.parse()?);
223            }
224            Long("allow-host") => {
225                let value: String = parser.value()?.parse()?;
226                result.allow_hosts.push(value);
227            }
228            Long("audit-log") => {
229                result.audit_log = Some(parser.value()?.parse()?);
230            }
231            Long("audit-max-bytes") => {
232                let value: String = parser.value()?.parse()?;
233                result.audit_max_bytes = Some(
234                    value
235                        .parse()
236                        .map_err(|_| ArgsError::InvalidValue("audit-max-bytes", value))?,
237                );
238            }
239            Long("cors-allow-any") => {
240                result.cors_allow_any = true;
241            }
242            Long("fs-root") => {
243                result.fs_root = Some(parser.value()?.parse()?);
244            }
245            Long("fs-chunk-size") => {
246                let value: String = parser.value()?.parse()?;
247                result.fs_chunk_size = Some(
248                    value
249                        .parse()
250                        .map_err(|_| ArgsError::InvalidValue("fs-chunk-size", value))?,
251                );
252            }
253            Short('l') | Long("log-level") => {
254                result.log_level = Some(parser.value()?.parse()?);
255            }
256            #[cfg(feature = "self-update")]
257            Long("check-update") => {
258                result.check_update = true;
259            }
260            #[cfg(feature = "self-update")]
261            Long("update") => {
262                result.update = true;
263            }
264            #[cfg(feature = "self-update")]
265            Long("no-update-check") => {
266                result.no_update_check = true;
267            }
268            // The only positional is the `relay` subcommand, which switches the
269            // binary into relay-server mode. Bind address and port keep using
270            // -H/-p so one CLI vocabulary covers both modes.
271            Value(val) if val == "relay" && !result.relay => {
272                result.relay = true;
273            }
274            Value(val) => {
275                return Err(ArgsError::UnexpectedArgument(val.to_string_lossy().into()));
276            }
277            _ => return Err(arg.unexpected().into()),
278        }
279    }
280
281    // A certificate without its key (or the reverse) cannot serve anything, and
282    // silently falling back to plaintext would be the opposite of what was asked.
283    if result.tls_cert.is_some() != result.tls_key.is_some() {
284        return Err(ArgsError::Conflicting("--tls-cert", "--tls-key"));
285    }
286
287    // `--tls-self-signed` needs no paths; naming them just says where to put it.
288    if result.tls_self_signed && result.tls_cert.is_none() {
289        let defaults = (
290            std::path::PathBuf::from("shell-tunnel-cert.pem"),
291            std::path::PathBuf::from("shell-tunnel-key.pem"),
292        );
293        result.tls_cert = Some(defaults.0);
294        result.tls_key = Some(defaults.1);
295    }
296
297    // Relay mode serves devices, not shells: a tunnel would publish the wrong
298    // thing entirely.
299    if result.relay && (result.tunnel || result.tunnel_command.is_some()) {
300        return Err(ArgsError::Conflicting("relay", "--tunnel"));
301    }
302    if result.relay_url.is_some() && result.tunnel {
303        return Err(ArgsError::Conflicting("--relay", "--tunnel"));
304    }
305    if result.relay_url.is_some() && result.tunnel_command.is_some() {
306        return Err(ArgsError::Conflicting("--relay", "--tunnel-command"));
307    }
308
309    // Reachability paths are mutually exclusive: two tunnels would each publish
310    // a different public URL for the same server, and only one can be reported.
311    if result.tunnel && result.tunnel_command.is_some() {
312        return Err(ArgsError::Conflicting("--tunnel", "--tunnel-command"));
313    }
314
315    Ok(result)
316}
317
318/// Print help message.
319pub fn print_help() {
320    let version = env!("CARGO_PKG_VERSION");
321
322    // Update flags exist only when compiled with the `self-update` feature.
323    #[cfg(feature = "self-update")]
324    let update_opts = "        --check-update      Check for updates and exit\n        --update            Download and install latest version\n        --no-update-check   Disable automatic update check on startup\n";
325    #[cfg(not(feature = "self-update"))]
326    let update_opts = "";
327
328    #[cfg(feature = "self-update")]
329    let update_examples = "\n    # Check for updates\n    shell-tunnel --check-update\n\n    # Self-update to latest version\n    shell-tunnel --update\n";
330    #[cfg(not(feature = "self-update"))]
331    let update_examples = "";
332
333    println!(
334        r#"shell-tunnel {version}
335Ultra-lightweight remote shell gateway with a REST/WebSocket API
336
337USAGE:
338    shell-tunnel [OPTIONS]              Serve a shell gateway
339    shell-tunnel relay [OPTIONS]        Serve a relay that devices dial out to
340
341OPTIONS:
342    -H, --host <ADDR>       Host address to bind [default: 127.0.0.1]
343    -p, --port <PORT>       Port to listen on [default: 3000]
344    -c, --config <FILE>     Path to configuration file (JSON)
345    -k, --api-key <KEY>     API key callers present to run commands here
346    -l, --log-level <LVL>   Log level (error, warn, info, debug, trace)
347        --no-auth           Disable authentication
348        --require-auth      Require auth, auto-generating an API key if none given
349        --capabilities <C>  Scope issued token(s): comma-separated capabilities
350                            (e.g. exec,session.read). Default: full-control
351        --preset <NAME>     Scope issued token(s) by role preset
352                            (operator | read-only | full-control)
353        --no-rate-limit     Disable rate limiting
354        --tunnel            Expose publicly via a Cloudflare quick tunnel
355                            (requires `cloudflared`; implies authentication)
356        --tunnel-command <C>
357                            Expose publicly by running an arbitrary tunnel
358                            command (ngrok, bore, frp, ...); its printed URL
359                            is used. Implies authentication
360        --relay <URL>       Attach to a self-hosted relay (dial out, no inbound
361                            port). Needs the relay's --enroll-token; implies
362                            authentication. The local port is chosen for you
363                            unless -p says otherwise
364        --device-name <N>   Claim a stable name on the relay, so the device URL
365                            survives reconnects [default: this machine's name]
366        --allow-host <HOST> Also answer to this host name. A loopback-bound
367                            server otherwise answers only to localhost, which is
368                            what stops DNS rebinding. Repeatable
369        --audit-log <FILE>  Append every execution and refusal to this file
370                            (JSON per line; the token itself is never written)
371        --audit-max-bytes <N>
372                            Rotate the audit trail to <FILE>.1 past this size
373                            [default: unbounded]
374        --cors-allow-any    Allow any CORS origin (opt-in; for browser UIs)
375        --fs-root <PATH>    Enable the file API, confined to this directory
376        --fs-chunk-size <N> Upload chunk size in bytes (default 4194304)
377
378TLS OPTIONS (serve HTTPS directly, no reverse proxy needed):
379        --tls-self-signed   Serve HTTPS with a self-signed certificate,
380                            generating one on first run and reusing it after.
381                            Needs no paths; devices trust it with --relay-ca
382        --tls-cert <FILE>   PEM certificate chain [default with --tls-self-signed:
383                            shell-tunnel-cert.pem]
384        --tls-key <FILE>    PEM private key matching the certificate
385        --relay-fingerprint <FP>
386                            Expect exactly this certificate from the relay, as
387                            printed by `shell-tunnel relay --tls-self-signed`.
388                            Nothing to copy but the string, and the certificate
389                            need not name the address being dialled
390        --relay-ca <FILE>   Also trust this PEM authority when dialling a relay
391                            (the alternative to a fingerprint, for a private CA)
392
393RELAY OPTIONS (with `relay`):
394        --enroll-token <T>  Secret devices present to attach to this relay
395                            (generated if unset). Distinct from --api-key, which
396                            is what callers present to a device
397        --public-base <URL> Public base URL of this relay. A URL with no port
398                            uses this relay's listen port; name a port only when
399                            a proxy remaps it [default: http://<bind address>]
400{update_opts}    -h, --help              Print help
401    -V, --version           Print version
402
403ENVIRONMENT VARIABLES:
404    SHELL_TUNNEL_HOST       Host address (overrides config)
405    SHELL_TUNNEL_PORT       Port number (overrides config)
406    SHELL_TUNNEL_API_KEY    API key (overrides config)
407    SHELL_TUNNEL_LOG_LEVEL  Log level (overrides config)
408    RUST_LOG                Alternative log level setting
409
410EXAMPLES:
411    # Start with defaults (localhost:3000, no auth)
412    shell-tunnel
413
414    # Start on all interfaces with API key
415    shell-tunnel -H 0.0.0.0 -p 8080 -k my-secret-key
416
417    # Start with config file
418    shell-tunnel -c /etc/shell-tunnel/config.json
419
420    # Development mode (no security)
421    shell-tunnel --no-auth --no-rate-limit
422
423    # Publish on the internet with a generated key (no account needed)
424    shell-tunnel --tunnel
425
426    # Publish using a different tunnel client
427    shell-tunnel --tunnel-command "ngrok http 3000"
428
429    # Attach to a relay under a stable name
430    shell-tunnel --relay https://relay.example.com --enroll-token <t> --device-name box
431
432    # Run a relay with HTTPS, generating a certificate on first run.
433    # --public-base names the host; the URL uses this relay's port (8443).
434    shell-tunnel relay -H 0.0.0.0 -p 8443 --tls-self-signed --public-base https://relay.example.com
435
436    # Behind a proxy that forwards 443 here, name the port devices dial
437    shell-tunnel relay -H 0.0.0.0 -p 8443 --public-base https://relay.example.com:443
438
439    # Issue a fine-grained, read-only token
440    shell-tunnel -k readonly-key --preset read-only
441
442    # Issue a token scoped to specific capabilities
443    shell-tunnel -k ci-key --capabilities exec,session.read
444{update_examples}"#
445    );
446}
447
448/// Print version.
449pub fn print_version() {
450    println!("shell-tunnel {}", env!("CARGO_PKG_VERSION"));
451}
452
453/// Argument parsing errors.
454#[derive(Debug)]
455pub enum ArgsError {
456    /// Lexopt parsing error.
457    Lexopt(lexopt::Error),
458    /// Invalid argument value.
459    InvalidValue(&'static str, String),
460    /// Unexpected positional argument.
461    UnexpectedArgument(String),
462    /// Two mutually exclusive flags were given.
463    Conflicting(&'static str, &'static str),
464}
465
466impl std::fmt::Display for ArgsError {
467    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
468        match self {
469            Self::Lexopt(e) => write!(f, "{}", e),
470            Self::InvalidValue(name, value) => {
471                write!(f, "invalid value for --{}: '{}'", name, value)
472            }
473            Self::UnexpectedArgument(arg) => {
474                write!(f, "unexpected argument: '{}'", arg)
475            }
476            Self::Conflicting(a, b) if a.starts_with("--tls") => {
477                write!(f, "{} and {} must be given together", a, b)
478            }
479            Self::Conflicting(a, b) => {
480                write!(f, "{} and {} cannot be used together", a, b)
481            }
482        }
483    }
484}
485
486impl std::error::Error for ArgsError {}
487
488impl From<lexopt::Error> for ArgsError {
489    fn from(e: lexopt::Error) -> Self {
490        Self::Lexopt(e)
491    }
492}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497
498    fn args(args: &[&str]) -> Vec<OsString> {
499        std::iter::once("shell-tunnel")
500            .chain(args.iter().copied())
501            .map(OsString::from)
502            .collect()
503    }
504
505    #[test]
506    fn test_default_args() {
507        let result = parse_args_from(args(&[])).unwrap();
508        assert_eq!(result.host.to_string(), "127.0.0.1");
509        assert_eq!(result.port, 3000);
510        assert!(!result.no_auth);
511    }
512
513    #[test]
514    fn test_host_port() {
515        let result = parse_args_from(args(&["-H", "0.0.0.0", "-p", "8080"])).unwrap();
516        assert_eq!(result.host.to_string(), "0.0.0.0");
517        assert_eq!(result.port, 8080);
518    }
519
520    #[test]
521    fn test_long_options() {
522        let result = parse_args_from(args(&["--host", "192.168.1.1", "--port", "9000"])).unwrap();
523        assert_eq!(result.host.to_string(), "192.168.1.1");
524        assert_eq!(result.port, 9000);
525    }
526
527    #[test]
528    fn test_api_key() {
529        let result = parse_args_from(args(&["-k", "my-secret"])).unwrap();
530        assert_eq!(result.api_key, Some("my-secret".to_string()));
531    }
532
533    #[test]
534    fn test_config_file() {
535        let result = parse_args_from(args(&["-c", "/etc/config.json"])).unwrap();
536        assert_eq!(result.config, Some(PathBuf::from("/etc/config.json")));
537    }
538
539    #[test]
540    fn test_no_auth() {
541        let result = parse_args_from(args(&["--no-auth"])).unwrap();
542        assert!(result.no_auth);
543    }
544
545    #[test]
546    fn test_require_auth() {
547        let result = parse_args_from(args(&["--require-auth"])).unwrap();
548        assert!(result.require_auth);
549        assert!(!Args::default().require_auth);
550    }
551
552    #[test]
553    fn test_no_rate_limit() {
554        let result = parse_args_from(args(&["--no-rate-limit"])).unwrap();
555        assert!(result.no_rate_limit);
556    }
557
558    #[test]
559    fn test_capabilities_csv() {
560        let result = parse_args_from(args(&["--capabilities", "exec,session.read"])).unwrap();
561        assert_eq!(result.capabilities, vec!["exec", "session.read"]);
562        assert!(Args::default().capabilities.is_empty());
563    }
564
565    #[test]
566    fn test_capabilities_trims_and_ignores_blanks() {
567        let result = parse_args_from(args(&["--capabilities", " exec , , session.read "])).unwrap();
568        assert_eq!(result.capabilities, vec!["exec", "session.read"]);
569    }
570
571    #[test]
572    fn test_capabilities_repeated_accumulate() {
573        let result = parse_args_from(args(&[
574            "--capabilities",
575            "exec",
576            "--capabilities",
577            "session.read,session.manage",
578        ]))
579        .unwrap();
580        assert_eq!(
581            result.capabilities,
582            vec!["exec", "session.read", "session.manage"]
583        );
584    }
585
586    #[test]
587    fn test_preset() {
588        let result = parse_args_from(args(&["--preset", "operator"])).unwrap();
589        assert_eq!(result.preset, Some("operator".to_string()));
590        assert!(Args::default().preset.is_none());
591    }
592
593    #[test]
594    fn test_help_flag() {
595        let result = parse_args_from(args(&["-h"])).unwrap();
596        assert!(result.help);
597
598        let result = parse_args_from(args(&["--help"])).unwrap();
599        assert!(result.help);
600    }
601
602    #[test]
603    fn test_version_flag() {
604        let result = parse_args_from(args(&["-V"])).unwrap();
605        assert!(result.version);
606
607        let result = parse_args_from(args(&["--version"])).unwrap();
608        assert!(result.version);
609    }
610
611    #[test]
612    fn test_log_level() {
613        let result = parse_args_from(args(&["-l", "debug"])).unwrap();
614        assert_eq!(result.log_level, Some("debug".to_string()));
615    }
616
617    #[test]
618    fn test_invalid_port() {
619        let result = parse_args_from(args(&["-p", "invalid"]));
620        assert!(result.is_err());
621    }
622
623    #[test]
624    fn test_invalid_host() {
625        let result = parse_args_from(args(&["-H", "not-an-ip"]));
626        assert!(result.is_err());
627    }
628
629    #[test]
630    fn test_combined_options() {
631        let result = parse_args_from(args(&[
632            "-H",
633            "0.0.0.0",
634            "-p",
635            "8080",
636            "-k",
637            "secret",
638            "-l",
639            "debug",
640            "--no-rate-limit",
641        ]))
642        .unwrap();
643
644        assert_eq!(result.host.to_string(), "0.0.0.0");
645        assert_eq!(result.port, 8080);
646        assert_eq!(result.api_key, Some("secret".to_string()));
647        assert_eq!(result.log_level, Some("debug".to_string()));
648        assert!(result.no_rate_limit);
649        assert!(!result.no_auth);
650    }
651
652    #[test]
653    fn test_tunnel_flag() {
654        let result = parse_args_from(vec![
655            OsString::from("shell-tunnel"),
656            OsString::from("--tunnel"),
657        ])
658        .unwrap();
659        assert!(result.tunnel);
660        assert!(result.tunnel_command.is_none());
661    }
662
663    #[test]
664    fn test_tunnel_command_flag() {
665        let result = parse_args_from(vec![
666            OsString::from("shell-tunnel"),
667            OsString::from("--tunnel-command"),
668            OsString::from("ngrok http 3000"),
669        ])
670        .unwrap();
671        assert_eq!(result.tunnel_command.as_deref(), Some("ngrok http 3000"));
672        assert!(!result.tunnel);
673    }
674
675    #[test]
676    fn test_tunnel_paths_are_mutually_exclusive() {
677        let err = parse_args_from(vec![
678            OsString::from("shell-tunnel"),
679            OsString::from("--tunnel"),
680            OsString::from("--tunnel-command"),
681            OsString::from("bore local 3000 --to bore.pub"),
682        ])
683        .unwrap_err();
684        let msg = err.to_string();
685        assert!(msg.contains("--tunnel"), "{msg}");
686        assert!(msg.contains("cannot be used together"), "{msg}");
687    }
688
689    #[test]
690    fn test_no_tunnel_by_default() {
691        let result = parse_args_from(vec![OsString::from("shell-tunnel")]).unwrap();
692        assert!(!result.tunnel);
693        assert!(result.tunnel_command.is_none());
694    }
695}