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 advertised to callers, in bytes.
405                            Default 4194304; 262144 when --relay is given,
406                            because a relayed chunk must also finish inside the
407                            relay's 120s request deadline
408
409TLS OPTIONS (with `relay`):
410        --tls-self-signed   Serve HTTPS with a self-signed certificate,
411                            generating one on first run and reusing it after.
412                            Needs no paths; devices trust it with the
413                            --relay-fingerprint the banner prints. Its names are
414                            fixed when it is generated, so adding --public-base
415                            later does not add that name — the banner says which
416                            names it actually covers
417        --tls-cert <FILE>   PEM certificate chain [default with --tls-self-signed:
418                            shell-tunnel-cert.pem]
419        --tls-key <FILE>    PEM private key matching the certificate
420
421                            A gateway does not serve HTTPS and refuses these
422                            flags at startup: reach it through a tunnel or a
423                            relay, which carry their own TLS, or put a reverse
424                            proxy in front. Its own socket is plaintext.
425                            With a proxy, pass --require-auth: the proxy does
426                            not change the bind address, so a loopback-bound
427                            gateway still counts itself local and leaves
428                            authentication off while the proxy publishes it
429
430RELAY OPTIONS (with `relay`):
431        --enroll-token <T>  Secret devices present to attach to this relay
432                            (generated if unset). Distinct from --api-key, which
433                            is what callers present to a device
434        --public-base <URL> Public base URL of this relay. A URL with no port
435                            uses this relay's listen port; name a port only when
436                            a proxy remaps it [default: http://<bind address>]
437
438OTHER OPTIONS:
439{update_opts}    -h, --help              Print help
440    -V, --version           Print version
441
442ENVIRONMENT VARIABLES:
443    SHELL_TUNNEL_HOST       Bind address, unless -H names one
444    SHELL_TUNNEL_PORT       Port, unless -p names one
445    SHELL_TUNNEL_API_KEY    Adds an API key and turns auth on. Keys from the
446                            config file stay valid alongside it
447    SHELL_TUNNEL_LOG_LEVEL  Log level (overrides config)
448    RUST_LOG                Alternative log level setting
449
450EXAMPLES:
451    # Start with defaults (localhost:3000, no auth)
452    shell-tunnel
453
454    # Start on all interfaces with API key
455    shell-tunnel -H 0.0.0.0 -p 8080 -k my-secret-key
456
457    # Start with config file
458    shell-tunnel -c /etc/shell-tunnel/config.json
459
460    # Development mode (no security)
461    shell-tunnel --no-auth --no-rate-limit
462
463    # Publish on the internet with a generated key (no account needed)
464    shell-tunnel --tunnel
465
466    # Publish using a different tunnel client
467    shell-tunnel --tunnel-command "ngrok http 3000"
468
469    # Attach to a relay under a stable name
470    shell-tunnel --relay https://relay.example.com --enroll-token <t> --device-name box
471
472    # Run a relay with HTTPS, generating a certificate on first run.
473    # --public-base names the host; the URL uses this relay's port (8443).
474    shell-tunnel relay -H 0.0.0.0 -p 8443 --tls-self-signed --public-base https://relay.example.com
475
476    # Behind a proxy that forwards 443 here, name the port devices dial
477    shell-tunnel relay -H 0.0.0.0 -p 8443 --public-base https://relay.example.com:443
478
479    # Issue a token that can only read files, confined to one directory
480    shell-tunnel -k readonly-key --preset file-read --fs-root /srv/deploy
481
482    # Issue a token scoped to specific capabilities
483    shell-tunnel -k ci-key --capabilities exec,session.read
484{update_examples}"#
485    );
486}
487
488/// Print version.
489pub fn print_version() {
490    println!("shell-tunnel {}", env!("CARGO_PKG_VERSION"));
491}
492
493/// Argument parsing errors.
494#[derive(Debug)]
495pub enum ArgsError {
496    /// Lexopt parsing error.
497    Lexopt(lexopt::Error),
498    /// Invalid argument value.
499    InvalidValue(&'static str, String),
500    /// Unexpected positional argument.
501    UnexpectedArgument(String),
502    /// Two mutually exclusive flags were given.
503    Conflicting(&'static str, &'static str),
504}
505
506impl std::fmt::Display for ArgsError {
507    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
508        match self {
509            Self::Lexopt(e) => write!(f, "{}", e),
510            Self::InvalidValue(name, value) => {
511                write!(f, "invalid value for --{}: '{}'", name, value)
512            }
513            Self::UnexpectedArgument(arg) => {
514                write!(f, "unexpected argument: '{}'", arg)
515            }
516            Self::Conflicting(a, b) if a.starts_with("--tls") => {
517                write!(f, "{} and {} must be given together", a, b)
518            }
519            Self::Conflicting(a, b) => {
520                write!(f, "{} and {} cannot be used together", a, b)
521            }
522        }
523    }
524}
525
526impl std::error::Error for ArgsError {}
527
528impl From<lexopt::Error> for ArgsError {
529    fn from(e: lexopt::Error) -> Self {
530        Self::Lexopt(e)
531    }
532}
533
534#[cfg(test)]
535mod tests {
536    use super::*;
537
538    fn args(args: &[&str]) -> Vec<OsString> {
539        std::iter::once("shell-tunnel")
540            .chain(args.iter().copied())
541            .map(OsString::from)
542            .collect()
543    }
544
545    #[test]
546    fn test_default_args() {
547        let result = parse_args_from(args(&[])).unwrap();
548        assert_eq!(result.host.to_string(), "127.0.0.1");
549        assert_eq!(result.port, 3000);
550        assert!(!result.no_auth);
551    }
552
553    #[test]
554    fn test_host_port() {
555        let result = parse_args_from(args(&["-H", "0.0.0.0", "-p", "8080"])).unwrap();
556        assert_eq!(result.host.to_string(), "0.0.0.0");
557        assert_eq!(result.port, 8080);
558    }
559
560    #[test]
561    fn test_long_options() {
562        let result = parse_args_from(args(&["--host", "192.168.1.1", "--port", "9000"])).unwrap();
563        assert_eq!(result.host.to_string(), "192.168.1.1");
564        assert_eq!(result.port, 9000);
565    }
566
567    #[test]
568    fn test_api_key() {
569        let result = parse_args_from(args(&["-k", "my-secret"])).unwrap();
570        assert_eq!(result.api_key, Some("my-secret".to_string()));
571    }
572
573    #[test]
574    fn test_config_file() {
575        let result = parse_args_from(args(&["-c", "/etc/config.json"])).unwrap();
576        assert_eq!(result.config, Some(PathBuf::from("/etc/config.json")));
577    }
578
579    #[test]
580    fn test_no_auth() {
581        let result = parse_args_from(args(&["--no-auth"])).unwrap();
582        assert!(result.no_auth);
583    }
584
585    #[test]
586    fn test_require_auth() {
587        let result = parse_args_from(args(&["--require-auth"])).unwrap();
588        assert!(result.require_auth);
589        assert!(!Args::default().require_auth);
590    }
591
592    #[test]
593    fn test_no_rate_limit() {
594        let result = parse_args_from(args(&["--no-rate-limit"])).unwrap();
595        assert!(result.no_rate_limit);
596    }
597
598    #[test]
599    fn test_capabilities_csv() {
600        let result = parse_args_from(args(&["--capabilities", "exec,session.read"])).unwrap();
601        assert_eq!(result.capabilities, vec!["exec", "session.read"]);
602        assert!(Args::default().capabilities.is_empty());
603    }
604
605    #[test]
606    fn test_capabilities_trims_and_ignores_blanks() {
607        let result = parse_args_from(args(&["--capabilities", " exec , , session.read "])).unwrap();
608        assert_eq!(result.capabilities, vec!["exec", "session.read"]);
609    }
610
611    #[test]
612    fn test_capabilities_repeated_accumulate() {
613        let result = parse_args_from(args(&[
614            "--capabilities",
615            "exec",
616            "--capabilities",
617            "session.read,session.manage",
618        ]))
619        .unwrap();
620        assert_eq!(
621            result.capabilities,
622            vec!["exec", "session.read", "session.manage"]
623        );
624    }
625
626    #[test]
627    fn test_preset() {
628        let result = parse_args_from(args(&["--preset", "operator"])).unwrap();
629        assert_eq!(result.preset, Some("operator".to_string()));
630        assert!(Args::default().preset.is_none());
631    }
632
633    #[test]
634    fn test_help_flag() {
635        let result = parse_args_from(args(&["-h"])).unwrap();
636        assert!(result.help);
637
638        let result = parse_args_from(args(&["--help"])).unwrap();
639        assert!(result.help);
640    }
641
642    #[test]
643    fn test_version_flag() {
644        let result = parse_args_from(args(&["-V"])).unwrap();
645        assert!(result.version);
646
647        let result = parse_args_from(args(&["--version"])).unwrap();
648        assert!(result.version);
649    }
650
651    #[test]
652    fn test_log_level() {
653        let result = parse_args_from(args(&["-l", "debug"])).unwrap();
654        assert_eq!(result.log_level, Some("debug".to_string()));
655    }
656
657    #[test]
658    fn test_invalid_port() {
659        let result = parse_args_from(args(&["-p", "invalid"]));
660        assert!(result.is_err());
661    }
662
663    #[test]
664    fn test_invalid_host() {
665        let result = parse_args_from(args(&["-H", "not-an-ip"]));
666        assert!(result.is_err());
667    }
668
669    #[test]
670    fn test_combined_options() {
671        let result = parse_args_from(args(&[
672            "-H",
673            "0.0.0.0",
674            "-p",
675            "8080",
676            "-k",
677            "secret",
678            "-l",
679            "debug",
680            "--no-rate-limit",
681        ]))
682        .unwrap();
683
684        assert_eq!(result.host.to_string(), "0.0.0.0");
685        assert_eq!(result.port, 8080);
686        assert_eq!(result.api_key, Some("secret".to_string()));
687        assert_eq!(result.log_level, Some("debug".to_string()));
688        assert!(result.no_rate_limit);
689        assert!(!result.no_auth);
690    }
691
692    #[test]
693    fn test_tunnel_flag() {
694        let result = parse_args_from(vec![
695            OsString::from("shell-tunnel"),
696            OsString::from("--tunnel"),
697        ])
698        .unwrap();
699        assert!(result.tunnel);
700        assert!(result.tunnel_command.is_none());
701    }
702
703    #[test]
704    fn test_tunnel_command_flag() {
705        let result = parse_args_from(vec![
706            OsString::from("shell-tunnel"),
707            OsString::from("--tunnel-command"),
708            OsString::from("ngrok http 3000"),
709        ])
710        .unwrap();
711        assert_eq!(result.tunnel_command.as_deref(), Some("ngrok http 3000"));
712        assert!(!result.tunnel);
713    }
714
715    #[test]
716    fn test_tunnel_paths_are_mutually_exclusive() {
717        let err = parse_args_from(vec![
718            OsString::from("shell-tunnel"),
719            OsString::from("--tunnel"),
720            OsString::from("--tunnel-command"),
721            OsString::from("bore local 3000 --to bore.pub"),
722        ])
723        .unwrap_err();
724        let msg = err.to_string();
725        assert!(msg.contains("--tunnel"), "{msg}");
726        assert!(msg.contains("cannot be used together"), "{msg}");
727    }
728
729    #[test]
730    fn test_no_tunnel_by_default() {
731        let result = parse_args_from(vec![OsString::from("shell-tunnel")]).unwrap();
732        assert!(!result.tunnel);
733        assert!(result.tunnel_command.is_none());
734    }
735}