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