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