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