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