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