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 audit_max_bytes: Option<u64>,
65 pub cors_allow_any: bool,
67 pub log_level: Option<String>,
69 pub version: bool,
71 pub help: bool,
73 pub check_update: bool,
75 pub update: bool,
77 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
120pub fn parse_args() -> Result<Args, ArgsError> {
122 parse_args_from(std::env::args_os())
123}
124
125pub 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 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 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 if result.tls_cert.is_some() != result.tls_key.is_some() {
267 return Err(ArgsError::Conflicting("--tls-cert", "--tls-key"));
268 }
269
270 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 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 if result.tunnel && result.tunnel_command.is_some() {
295 return Err(ArgsError::Conflicting("--tunnel", "--tunnel-command"));
296 }
297
298 Ok(result)
299}
300
301pub fn print_help() {
303 let version = env!("CARGO_PKG_VERSION");
304
305 #[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
429pub fn print_version() {
431 println!("shell-tunnel {}", env!("CARGO_PKG_VERSION"));
432}
433
434#[derive(Debug)]
436pub enum ArgsError {
437 Lexopt(lexopt::Error),
439 InvalidValue(&'static str, String),
441 UnexpectedArgument(String),
443 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}