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. A URL with no port
379                            uses this relay's listen port; name a port only when
380                            a proxy remaps it [default: http://<bind address>]
381{update_opts}    -h, --help              Print help
382    -V, --version           Print version
383
384ENVIRONMENT VARIABLES:
385    SHELL_TUNNEL_HOST       Host address (overrides config)
386    SHELL_TUNNEL_PORT       Port number (overrides config)
387    SHELL_TUNNEL_API_KEY    API key (overrides config)
388    SHELL_TUNNEL_LOG_LEVEL  Log level (overrides config)
389    RUST_LOG                Alternative log level setting
390
391EXAMPLES:
392    # Start with defaults (localhost:3000, no auth)
393    shell-tunnel
394
395    # Start on all interfaces with API key
396    shell-tunnel -H 0.0.0.0 -p 8080 -k my-secret-key
397
398    # Start with config file
399    shell-tunnel -c /etc/shell-tunnel/config.json
400
401    # Development mode (no security)
402    shell-tunnel --no-auth --no-rate-limit
403
404    # Publish on the internet with a generated key (no account needed)
405    shell-tunnel --tunnel
406
407    # Publish using a different tunnel client
408    shell-tunnel --tunnel-command "ngrok http 3000"
409
410    # Attach to a relay under a stable name
411    shell-tunnel --relay https://relay.example.com --enroll-token <t> --device-name box
412
413    # Run a relay with HTTPS, generating a certificate on first run.
414    # --public-base names the host; the URL uses this relay's port (8443).
415    shell-tunnel relay -H 0.0.0.0 -p 8443 --tls-self-signed --public-base https://relay.example.com
416
417    # Behind a proxy that forwards 443 here, name the port devices dial
418    shell-tunnel relay -H 0.0.0.0 -p 8443 --public-base https://relay.example.com:443
419
420    # Issue a fine-grained, read-only token
421    shell-tunnel -k readonly-key --preset read-only
422
423    # Issue a token scoped to specific capabilities
424    shell-tunnel -k ci-key --capabilities exec,session.read
425{update_examples}"#
426    );
427}
428
429/// Print version.
430pub fn print_version() {
431    println!("shell-tunnel {}", env!("CARGO_PKG_VERSION"));
432}
433
434/// Argument parsing errors.
435#[derive(Debug)]
436pub enum ArgsError {
437    /// Lexopt parsing error.
438    Lexopt(lexopt::Error),
439    /// Invalid argument value.
440    InvalidValue(&'static str, String),
441    /// Unexpected positional argument.
442    UnexpectedArgument(String),
443    /// Two mutually exclusive flags were given.
444    Conflicting(&'static str, &'static str),
445}
446
447impl std::fmt::Display for ArgsError {
448    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
449        match self {
450            Self::Lexopt(e) => write!(f, "{}", e),
451            Self::InvalidValue(name, value) => {
452                write!(f, "invalid value for --{}: '{}'", name, value)
453            }
454            Self::UnexpectedArgument(arg) => {
455                write!(f, "unexpected argument: '{}'", arg)
456            }
457            Self::Conflicting(a, b) if a.starts_with("--tls") => {
458                write!(f, "{} and {} must be given together", a, b)
459            }
460            Self::Conflicting(a, b) => {
461                write!(f, "{} and {} cannot be used together", a, b)
462            }
463        }
464    }
465}
466
467impl std::error::Error for ArgsError {}
468
469impl From<lexopt::Error> for ArgsError {
470    fn from(e: lexopt::Error) -> Self {
471        Self::Lexopt(e)
472    }
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478
479    fn args(args: &[&str]) -> Vec<OsString> {
480        std::iter::once("shell-tunnel")
481            .chain(args.iter().copied())
482            .map(OsString::from)
483            .collect()
484    }
485
486    #[test]
487    fn test_default_args() {
488        let result = parse_args_from(args(&[])).unwrap();
489        assert_eq!(result.host.to_string(), "127.0.0.1");
490        assert_eq!(result.port, 3000);
491        assert!(!result.no_auth);
492    }
493
494    #[test]
495    fn test_host_port() {
496        let result = parse_args_from(args(&["-H", "0.0.0.0", "-p", "8080"])).unwrap();
497        assert_eq!(result.host.to_string(), "0.0.0.0");
498        assert_eq!(result.port, 8080);
499    }
500
501    #[test]
502    fn test_long_options() {
503        let result = parse_args_from(args(&["--host", "192.168.1.1", "--port", "9000"])).unwrap();
504        assert_eq!(result.host.to_string(), "192.168.1.1");
505        assert_eq!(result.port, 9000);
506    }
507
508    #[test]
509    fn test_api_key() {
510        let result = parse_args_from(args(&["-k", "my-secret"])).unwrap();
511        assert_eq!(result.api_key, Some("my-secret".to_string()));
512    }
513
514    #[test]
515    fn test_config_file() {
516        let result = parse_args_from(args(&["-c", "/etc/config.json"])).unwrap();
517        assert_eq!(result.config, Some(PathBuf::from("/etc/config.json")));
518    }
519
520    #[test]
521    fn test_no_auth() {
522        let result = parse_args_from(args(&["--no-auth"])).unwrap();
523        assert!(result.no_auth);
524    }
525
526    #[test]
527    fn test_require_auth() {
528        let result = parse_args_from(args(&["--require-auth"])).unwrap();
529        assert!(result.require_auth);
530        assert!(!Args::default().require_auth);
531    }
532
533    #[test]
534    fn test_no_rate_limit() {
535        let result = parse_args_from(args(&["--no-rate-limit"])).unwrap();
536        assert!(result.no_rate_limit);
537    }
538
539    #[test]
540    fn test_capabilities_csv() {
541        let result = parse_args_from(args(&["--capabilities", "exec,session.read"])).unwrap();
542        assert_eq!(result.capabilities, vec!["exec", "session.read"]);
543        assert!(Args::default().capabilities.is_empty());
544    }
545
546    #[test]
547    fn test_capabilities_trims_and_ignores_blanks() {
548        let result = parse_args_from(args(&["--capabilities", " exec , , session.read "])).unwrap();
549        assert_eq!(result.capabilities, vec!["exec", "session.read"]);
550    }
551
552    #[test]
553    fn test_capabilities_repeated_accumulate() {
554        let result = parse_args_from(args(&[
555            "--capabilities",
556            "exec",
557            "--capabilities",
558            "session.read,session.manage",
559        ]))
560        .unwrap();
561        assert_eq!(
562            result.capabilities,
563            vec!["exec", "session.read", "session.manage"]
564        );
565    }
566
567    #[test]
568    fn test_preset() {
569        let result = parse_args_from(args(&["--preset", "operator"])).unwrap();
570        assert_eq!(result.preset, Some("operator".to_string()));
571        assert!(Args::default().preset.is_none());
572    }
573
574    #[test]
575    fn test_help_flag() {
576        let result = parse_args_from(args(&["-h"])).unwrap();
577        assert!(result.help);
578
579        let result = parse_args_from(args(&["--help"])).unwrap();
580        assert!(result.help);
581    }
582
583    #[test]
584    fn test_version_flag() {
585        let result = parse_args_from(args(&["-V"])).unwrap();
586        assert!(result.version);
587
588        let result = parse_args_from(args(&["--version"])).unwrap();
589        assert!(result.version);
590    }
591
592    #[test]
593    fn test_log_level() {
594        let result = parse_args_from(args(&["-l", "debug"])).unwrap();
595        assert_eq!(result.log_level, Some("debug".to_string()));
596    }
597
598    #[test]
599    fn test_invalid_port() {
600        let result = parse_args_from(args(&["-p", "invalid"]));
601        assert!(result.is_err());
602    }
603
604    #[test]
605    fn test_invalid_host() {
606        let result = parse_args_from(args(&["-H", "not-an-ip"]));
607        assert!(result.is_err());
608    }
609
610    #[test]
611    fn test_combined_options() {
612        let result = parse_args_from(args(&[
613            "-H",
614            "0.0.0.0",
615            "-p",
616            "8080",
617            "-k",
618            "secret",
619            "-l",
620            "debug",
621            "--no-rate-limit",
622        ]))
623        .unwrap();
624
625        assert_eq!(result.host.to_string(), "0.0.0.0");
626        assert_eq!(result.port, 8080);
627        assert_eq!(result.api_key, Some("secret".to_string()));
628        assert_eq!(result.log_level, Some("debug".to_string()));
629        assert!(result.no_rate_limit);
630        assert!(!result.no_auth);
631    }
632
633    #[test]
634    fn test_tunnel_flag() {
635        let result = parse_args_from(vec![
636            OsString::from("shell-tunnel"),
637            OsString::from("--tunnel"),
638        ])
639        .unwrap();
640        assert!(result.tunnel);
641        assert!(result.tunnel_command.is_none());
642    }
643
644    #[test]
645    fn test_tunnel_command_flag() {
646        let result = parse_args_from(vec![
647            OsString::from("shell-tunnel"),
648            OsString::from("--tunnel-command"),
649            OsString::from("ngrok http 3000"),
650        ])
651        .unwrap();
652        assert_eq!(result.tunnel_command.as_deref(), Some("ngrok http 3000"));
653        assert!(!result.tunnel);
654    }
655
656    #[test]
657    fn test_tunnel_paths_are_mutually_exclusive() {
658        let err = parse_args_from(vec![
659            OsString::from("shell-tunnel"),
660            OsString::from("--tunnel"),
661            OsString::from("--tunnel-command"),
662            OsString::from("bore local 3000 --to bore.pub"),
663        ])
664        .unwrap_err();
665        let msg = err.to_string();
666        assert!(msg.contains("--tunnel"), "{msg}");
667        assert!(msg.contains("cannot be used together"), "{msg}");
668    }
669
670    #[test]
671    fn test_no_tunnel_by_default() {
672        let result = parse_args_from(vec![OsString::from("shell-tunnel")]).unwrap();
673        assert!(!result.tunnel);
674        assert!(result.tunnel_command.is_none());
675    }
676}