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 --capabilities <C> Scope issued token(s): comma-separated capabilities
364 (e.g. exec,session.read). Default: full-control, or
365 operator when the server is reachable
366 --preset <NAME> Scope issued token(s) by role preset
367 (operator | file-write | file-read | full-control)
368 --no-rate-limit Disable rate limiting
369 --tunnel Expose publicly via a Cloudflare quick tunnel
370 (requires `cloudflared`; implies authentication)
371 --tunnel-command <C>
372 Expose publicly by running an arbitrary tunnel
373 command (ngrok, bore, frp, ...); its printed URL
374 is used. Implies authentication
375 --relay <URL> Attach to a self-hosted relay (dial out, no inbound
376 port). Needs the relay's --enroll-token; implies
377 authentication. The local port is chosen for you
378 unless -p says otherwise
379 --device-name <N> Claim a stable name on the relay, so the device URL
380 survives reconnects [default: this machine's name]
381 --allow-host <HOST> Also answer to this host name. A loopback-bound
382 server that is not published otherwise answers only
383 to localhost, which is what stops DNS rebinding.
384 Published, nothing is host-checked. Repeatable
385 --audit-log <FILE> Append executions, denied requests, and file
386 operations to this file (JSON per line; the token
387 itself is never written)
388 [default: off; shell-tunnel-audit.jsonl when reachable]
389 --audit-max-bytes <N>
390 Rotate the audit trail to <FILE>.1 past this size
391 [default: unbounded]
392 --cors-allow-any Allow any CORS origin (opt-in; for browser UIs)
393 --fs-root <PATH> Confine the file API to this directory. Without it
394 the API reaches everything this account can
395 --fs-chunk-size <N> Upload chunk size in bytes (default 4194304)
396
397TLS OPTIONS (serve HTTPS directly, no reverse proxy needed):
398 --tls-self-signed Serve HTTPS with a self-signed certificate,
399 generating one on first run and reusing it after.
400 Needs no paths; devices trust it with --relay-ca
401 --tls-cert <FILE> PEM certificate chain [default with --tls-self-signed:
402 shell-tunnel-cert.pem]
403 --tls-key <FILE> PEM private key matching the certificate
404 --relay-fingerprint <FP>
405 Expect exactly this certificate from the relay, as
406 printed by `shell-tunnel relay --tls-self-signed`.
407 Nothing to copy but the string, and the certificate
408 need not name the address being dialled
409 --relay-ca <FILE> Also trust this PEM authority when dialling a relay
410 (the alternative to a fingerprint, for a private CA)
411
412RELAY OPTIONS (with `relay`):
413 --enroll-token <T> Secret devices present to attach to this relay
414 (generated if unset). Distinct from --api-key, which
415 is what callers present to a device
416 --public-base <URL> Public base URL of this relay. A URL with no port
417 uses this relay's listen port; name a port only when
418 a proxy remaps it [default: http://<bind address>]
419{update_opts} -h, --help Print help
420 -V, --version Print version
421
422ENVIRONMENT VARIABLES:
423 SHELL_TUNNEL_HOST Bind address, unless -H names one
424 SHELL_TUNNEL_PORT Port, unless -p names one
425 SHELL_TUNNEL_API_KEY Adds an API key and turns auth on. Keys from the
426 config file stay valid alongside it
427 SHELL_TUNNEL_LOG_LEVEL Log level (overrides config)
428 RUST_LOG Alternative log level setting
429
430EXAMPLES:
431 # Start with defaults (localhost:3000, no auth)
432 shell-tunnel
433
434 # Start on all interfaces with API key
435 shell-tunnel -H 0.0.0.0 -p 8080 -k my-secret-key
436
437 # Start with config file
438 shell-tunnel -c /etc/shell-tunnel/config.json
439
440 # Development mode (no security)
441 shell-tunnel --no-auth --no-rate-limit
442
443 # Publish on the internet with a generated key (no account needed)
444 shell-tunnel --tunnel
445
446 # Publish using a different tunnel client
447 shell-tunnel --tunnel-command "ngrok http 3000"
448
449 # Attach to a relay under a stable name
450 shell-tunnel --relay https://relay.example.com --enroll-token <t> --device-name box
451
452 # Run a relay with HTTPS, generating a certificate on first run.
453 # --public-base names the host; the URL uses this relay's port (8443).
454 shell-tunnel relay -H 0.0.0.0 -p 8443 --tls-self-signed --public-base https://relay.example.com
455
456 # Behind a proxy that forwards 443 here, name the port devices dial
457 shell-tunnel relay -H 0.0.0.0 -p 8443 --public-base https://relay.example.com:443
458
459 # Issue a token that can only read files, confined to one directory
460 shell-tunnel -k readonly-key --preset file-read --fs-root /srv/deploy
461
462 # Issue a token scoped to specific capabilities
463 shell-tunnel -k ci-key --capabilities exec,session.read
464{update_examples}"#
465 );
466}
467
468pub fn print_version() {
470 println!("shell-tunnel {}", env!("CARGO_PKG_VERSION"));
471}
472
473#[derive(Debug)]
475pub enum ArgsError {
476 Lexopt(lexopt::Error),
478 InvalidValue(&'static str, String),
480 UnexpectedArgument(String),
482 Conflicting(&'static str, &'static str),
484}
485
486impl std::fmt::Display for ArgsError {
487 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
488 match self {
489 Self::Lexopt(e) => write!(f, "{}", e),
490 Self::InvalidValue(name, value) => {
491 write!(f, "invalid value for --{}: '{}'", name, value)
492 }
493 Self::UnexpectedArgument(arg) => {
494 write!(f, "unexpected argument: '{}'", arg)
495 }
496 Self::Conflicting(a, b) if a.starts_with("--tls") => {
497 write!(f, "{} and {} must be given together", a, b)
498 }
499 Self::Conflicting(a, b) => {
500 write!(f, "{} and {} cannot be used together", a, b)
501 }
502 }
503 }
504}
505
506impl std::error::Error for ArgsError {}
507
508impl From<lexopt::Error> for ArgsError {
509 fn from(e: lexopt::Error) -> Self {
510 Self::Lexopt(e)
511 }
512}
513
514#[cfg(test)]
515mod tests {
516 use super::*;
517
518 fn args(args: &[&str]) -> Vec<OsString> {
519 std::iter::once("shell-tunnel")
520 .chain(args.iter().copied())
521 .map(OsString::from)
522 .collect()
523 }
524
525 #[test]
526 fn test_default_args() {
527 let result = parse_args_from(args(&[])).unwrap();
528 assert_eq!(result.host.to_string(), "127.0.0.1");
529 assert_eq!(result.port, 3000);
530 assert!(!result.no_auth);
531 }
532
533 #[test]
534 fn test_host_port() {
535 let result = parse_args_from(args(&["-H", "0.0.0.0", "-p", "8080"])).unwrap();
536 assert_eq!(result.host.to_string(), "0.0.0.0");
537 assert_eq!(result.port, 8080);
538 }
539
540 #[test]
541 fn test_long_options() {
542 let result = parse_args_from(args(&["--host", "192.168.1.1", "--port", "9000"])).unwrap();
543 assert_eq!(result.host.to_string(), "192.168.1.1");
544 assert_eq!(result.port, 9000);
545 }
546
547 #[test]
548 fn test_api_key() {
549 let result = parse_args_from(args(&["-k", "my-secret"])).unwrap();
550 assert_eq!(result.api_key, Some("my-secret".to_string()));
551 }
552
553 #[test]
554 fn test_config_file() {
555 let result = parse_args_from(args(&["-c", "/etc/config.json"])).unwrap();
556 assert_eq!(result.config, Some(PathBuf::from("/etc/config.json")));
557 }
558
559 #[test]
560 fn test_no_auth() {
561 let result = parse_args_from(args(&["--no-auth"])).unwrap();
562 assert!(result.no_auth);
563 }
564
565 #[test]
566 fn test_require_auth() {
567 let result = parse_args_from(args(&["--require-auth"])).unwrap();
568 assert!(result.require_auth);
569 assert!(!Args::default().require_auth);
570 }
571
572 #[test]
573 fn test_no_rate_limit() {
574 let result = parse_args_from(args(&["--no-rate-limit"])).unwrap();
575 assert!(result.no_rate_limit);
576 }
577
578 #[test]
579 fn test_capabilities_csv() {
580 let result = parse_args_from(args(&["--capabilities", "exec,session.read"])).unwrap();
581 assert_eq!(result.capabilities, vec!["exec", "session.read"]);
582 assert!(Args::default().capabilities.is_empty());
583 }
584
585 #[test]
586 fn test_capabilities_trims_and_ignores_blanks() {
587 let result = parse_args_from(args(&["--capabilities", " exec , , session.read "])).unwrap();
588 assert_eq!(result.capabilities, vec!["exec", "session.read"]);
589 }
590
591 #[test]
592 fn test_capabilities_repeated_accumulate() {
593 let result = parse_args_from(args(&[
594 "--capabilities",
595 "exec",
596 "--capabilities",
597 "session.read,session.manage",
598 ]))
599 .unwrap();
600 assert_eq!(
601 result.capabilities,
602 vec!["exec", "session.read", "session.manage"]
603 );
604 }
605
606 #[test]
607 fn test_preset() {
608 let result = parse_args_from(args(&["--preset", "operator"])).unwrap();
609 assert_eq!(result.preset, Some("operator".to_string()));
610 assert!(Args::default().preset.is_none());
611 }
612
613 #[test]
614 fn test_help_flag() {
615 let result = parse_args_from(args(&["-h"])).unwrap();
616 assert!(result.help);
617
618 let result = parse_args_from(args(&["--help"])).unwrap();
619 assert!(result.help);
620 }
621
622 #[test]
623 fn test_version_flag() {
624 let result = parse_args_from(args(&["-V"])).unwrap();
625 assert!(result.version);
626
627 let result = parse_args_from(args(&["--version"])).unwrap();
628 assert!(result.version);
629 }
630
631 #[test]
632 fn test_log_level() {
633 let result = parse_args_from(args(&["-l", "debug"])).unwrap();
634 assert_eq!(result.log_level, Some("debug".to_string()));
635 }
636
637 #[test]
638 fn test_invalid_port() {
639 let result = parse_args_from(args(&["-p", "invalid"]));
640 assert!(result.is_err());
641 }
642
643 #[test]
644 fn test_invalid_host() {
645 let result = parse_args_from(args(&["-H", "not-an-ip"]));
646 assert!(result.is_err());
647 }
648
649 #[test]
650 fn test_combined_options() {
651 let result = parse_args_from(args(&[
652 "-H",
653 "0.0.0.0",
654 "-p",
655 "8080",
656 "-k",
657 "secret",
658 "-l",
659 "debug",
660 "--no-rate-limit",
661 ]))
662 .unwrap();
663
664 assert_eq!(result.host.to_string(), "0.0.0.0");
665 assert_eq!(result.port, 8080);
666 assert_eq!(result.api_key, Some("secret".to_string()));
667 assert_eq!(result.log_level, Some("debug".to_string()));
668 assert!(result.no_rate_limit);
669 assert!(!result.no_auth);
670 }
671
672 #[test]
673 fn test_tunnel_flag() {
674 let result = parse_args_from(vec![
675 OsString::from("shell-tunnel"),
676 OsString::from("--tunnel"),
677 ])
678 .unwrap();
679 assert!(result.tunnel);
680 assert!(result.tunnel_command.is_none());
681 }
682
683 #[test]
684 fn test_tunnel_command_flag() {
685 let result = parse_args_from(vec![
686 OsString::from("shell-tunnel"),
687 OsString::from("--tunnel-command"),
688 OsString::from("ngrok http 3000"),
689 ])
690 .unwrap();
691 assert_eq!(result.tunnel_command.as_deref(), Some("ngrok http 3000"));
692 assert!(!result.tunnel);
693 }
694
695 #[test]
696 fn test_tunnel_paths_are_mutually_exclusive() {
697 let err = parse_args_from(vec![
698 OsString::from("shell-tunnel"),
699 OsString::from("--tunnel"),
700 OsString::from("--tunnel-command"),
701 OsString::from("bore local 3000 --to bore.pub"),
702 ])
703 .unwrap_err();
704 let msg = err.to_string();
705 assert!(msg.contains("--tunnel"), "{msg}");
706 assert!(msg.contains("cannot be used together"), "{msg}");
707 }
708
709 #[test]
710 fn test_no_tunnel_by_default() {
711 let result = parse_args_from(vec![OsString::from("shell-tunnel")]).unwrap();
712 assert!(!result.tunnel);
713 assert!(result.tunnel_command.is_none());
714 }
715}