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