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
379 [default: http://<bind address>]
380{update_opts} -h, --help Print help
381 -V, --version Print version
382
383ENVIRONMENT VARIABLES:
384 SHELL_TUNNEL_HOST Host address (overrides config)
385 SHELL_TUNNEL_PORT Port number (overrides config)
386 SHELL_TUNNEL_API_KEY API key (overrides config)
387 SHELL_TUNNEL_LOG_LEVEL Log level (overrides config)
388 RUST_LOG Alternative log level setting
389
390EXAMPLES:
391 # Start with defaults (localhost:3000, no auth)
392 shell-tunnel
393
394 # Start on all interfaces with API key
395 shell-tunnel -H 0.0.0.0 -p 8080 -k my-secret-key
396
397 # Start with config file
398 shell-tunnel -c /etc/shell-tunnel/config.json
399
400 # Development mode (no security)
401 shell-tunnel --no-auth --no-rate-limit
402
403 # Publish on the internet with a generated key (no account needed)
404 shell-tunnel --tunnel
405
406 # Publish using a different tunnel client
407 shell-tunnel --tunnel-command "ngrok http 3000"
408
409 # Attach to a relay under a stable name
410 shell-tunnel --relay https://relay.example.com --enroll-token <t> --device-name box
411
412 # Run a relay with HTTPS, generating a certificate on first run
413 shell-tunnel relay -H 0.0.0.0 -p 8443 --tls-self-signed --public-base https://relay.example.com:8443
414
415 # Run a relay behind a proxy that forwards port 443 to it
416 shell-tunnel relay -H 0.0.0.0 -p 8443 --public-base https://relay.example.com
417
418 # Issue a fine-grained, read-only token
419 shell-tunnel -k readonly-key --preset read-only
420
421 # Issue a token scoped to specific capabilities
422 shell-tunnel -k ci-key --capabilities exec,session.read
423{update_examples}"#
424 );
425}
426
427pub fn print_version() {
429 println!("shell-tunnel {}", env!("CARGO_PKG_VERSION"));
430}
431
432#[derive(Debug)]
434pub enum ArgsError {
435 Lexopt(lexopt::Error),
437 InvalidValue(&'static str, String),
439 UnexpectedArgument(String),
441 Conflicting(&'static str, &'static str),
443}
444
445impl std::fmt::Display for ArgsError {
446 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
447 match self {
448 Self::Lexopt(e) => write!(f, "{}", e),
449 Self::InvalidValue(name, value) => {
450 write!(f, "invalid value for --{}: '{}'", name, value)
451 }
452 Self::UnexpectedArgument(arg) => {
453 write!(f, "unexpected argument: '{}'", arg)
454 }
455 Self::Conflicting(a, b) if a.starts_with("--tls") => {
456 write!(f, "{} and {} must be given together", a, b)
457 }
458 Self::Conflicting(a, b) => {
459 write!(f, "{} and {} cannot be used together", a, b)
460 }
461 }
462 }
463}
464
465impl std::error::Error for ArgsError {}
466
467impl From<lexopt::Error> for ArgsError {
468 fn from(e: lexopt::Error) -> Self {
469 Self::Lexopt(e)
470 }
471}
472
473#[cfg(test)]
474mod tests {
475 use super::*;
476
477 fn args(args: &[&str]) -> Vec<OsString> {
478 std::iter::once("shell-tunnel")
479 .chain(args.iter().copied())
480 .map(OsString::from)
481 .collect()
482 }
483
484 #[test]
485 fn test_default_args() {
486 let result = parse_args_from(args(&[])).unwrap();
487 assert_eq!(result.host.to_string(), "127.0.0.1");
488 assert_eq!(result.port, 3000);
489 assert!(!result.no_auth);
490 }
491
492 #[test]
493 fn test_host_port() {
494 let result = parse_args_from(args(&["-H", "0.0.0.0", "-p", "8080"])).unwrap();
495 assert_eq!(result.host.to_string(), "0.0.0.0");
496 assert_eq!(result.port, 8080);
497 }
498
499 #[test]
500 fn test_long_options() {
501 let result = parse_args_from(args(&["--host", "192.168.1.1", "--port", "9000"])).unwrap();
502 assert_eq!(result.host.to_string(), "192.168.1.1");
503 assert_eq!(result.port, 9000);
504 }
505
506 #[test]
507 fn test_api_key() {
508 let result = parse_args_from(args(&["-k", "my-secret"])).unwrap();
509 assert_eq!(result.api_key, Some("my-secret".to_string()));
510 }
511
512 #[test]
513 fn test_config_file() {
514 let result = parse_args_from(args(&["-c", "/etc/config.json"])).unwrap();
515 assert_eq!(result.config, Some(PathBuf::from("/etc/config.json")));
516 }
517
518 #[test]
519 fn test_no_auth() {
520 let result = parse_args_from(args(&["--no-auth"])).unwrap();
521 assert!(result.no_auth);
522 }
523
524 #[test]
525 fn test_require_auth() {
526 let result = parse_args_from(args(&["--require-auth"])).unwrap();
527 assert!(result.require_auth);
528 assert!(!Args::default().require_auth);
529 }
530
531 #[test]
532 fn test_no_rate_limit() {
533 let result = parse_args_from(args(&["--no-rate-limit"])).unwrap();
534 assert!(result.no_rate_limit);
535 }
536
537 #[test]
538 fn test_capabilities_csv() {
539 let result = parse_args_from(args(&["--capabilities", "exec,session.read"])).unwrap();
540 assert_eq!(result.capabilities, vec!["exec", "session.read"]);
541 assert!(Args::default().capabilities.is_empty());
542 }
543
544 #[test]
545 fn test_capabilities_trims_and_ignores_blanks() {
546 let result = parse_args_from(args(&["--capabilities", " exec , , session.read "])).unwrap();
547 assert_eq!(result.capabilities, vec!["exec", "session.read"]);
548 }
549
550 #[test]
551 fn test_capabilities_repeated_accumulate() {
552 let result = parse_args_from(args(&[
553 "--capabilities",
554 "exec",
555 "--capabilities",
556 "session.read,session.manage",
557 ]))
558 .unwrap();
559 assert_eq!(
560 result.capabilities,
561 vec!["exec", "session.read", "session.manage"]
562 );
563 }
564
565 #[test]
566 fn test_preset() {
567 let result = parse_args_from(args(&["--preset", "operator"])).unwrap();
568 assert_eq!(result.preset, Some("operator".to_string()));
569 assert!(Args::default().preset.is_none());
570 }
571
572 #[test]
573 fn test_help_flag() {
574 let result = parse_args_from(args(&["-h"])).unwrap();
575 assert!(result.help);
576
577 let result = parse_args_from(args(&["--help"])).unwrap();
578 assert!(result.help);
579 }
580
581 #[test]
582 fn test_version_flag() {
583 let result = parse_args_from(args(&["-V"])).unwrap();
584 assert!(result.version);
585
586 let result = parse_args_from(args(&["--version"])).unwrap();
587 assert!(result.version);
588 }
589
590 #[test]
591 fn test_log_level() {
592 let result = parse_args_from(args(&["-l", "debug"])).unwrap();
593 assert_eq!(result.log_level, Some("debug".to_string()));
594 }
595
596 #[test]
597 fn test_invalid_port() {
598 let result = parse_args_from(args(&["-p", "invalid"]));
599 assert!(result.is_err());
600 }
601
602 #[test]
603 fn test_invalid_host() {
604 let result = parse_args_from(args(&["-H", "not-an-ip"]));
605 assert!(result.is_err());
606 }
607
608 #[test]
609 fn test_combined_options() {
610 let result = parse_args_from(args(&[
611 "-H",
612 "0.0.0.0",
613 "-p",
614 "8080",
615 "-k",
616 "secret",
617 "-l",
618 "debug",
619 "--no-rate-limit",
620 ]))
621 .unwrap();
622
623 assert_eq!(result.host.to_string(), "0.0.0.0");
624 assert_eq!(result.port, 8080);
625 assert_eq!(result.api_key, Some("secret".to_string()));
626 assert_eq!(result.log_level, Some("debug".to_string()));
627 assert!(result.no_rate_limit);
628 assert!(!result.no_auth);
629 }
630
631 #[test]
632 fn test_tunnel_flag() {
633 let result = parse_args_from(vec![
634 OsString::from("shell-tunnel"),
635 OsString::from("--tunnel"),
636 ])
637 .unwrap();
638 assert!(result.tunnel);
639 assert!(result.tunnel_command.is_none());
640 }
641
642 #[test]
643 fn test_tunnel_command_flag() {
644 let result = parse_args_from(vec![
645 OsString::from("shell-tunnel"),
646 OsString::from("--tunnel-command"),
647 OsString::from("ngrok http 3000"),
648 ])
649 .unwrap();
650 assert_eq!(result.tunnel_command.as_deref(), Some("ngrok http 3000"));
651 assert!(!result.tunnel);
652 }
653
654 #[test]
655 fn test_tunnel_paths_are_mutually_exclusive() {
656 let err = parse_args_from(vec![
657 OsString::from("shell-tunnel"),
658 OsString::from("--tunnel"),
659 OsString::from("--tunnel-command"),
660 OsString::from("bore local 3000 --to bore.pub"),
661 ])
662 .unwrap_err();
663 let msg = err.to_string();
664 assert!(msg.contains("--tunnel"), "{msg}");
665 assert!(msg.contains("cannot be used together"), "{msg}");
666 }
667
668 #[test]
669 fn test_no_tunnel_by_default() {
670 let result = parse_args_from(vec![OsString::from("shell-tunnel")]).unwrap();
671 assert!(!result.tunnel);
672 assert!(result.tunnel_command.is_none());
673 }
674}