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