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