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