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