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