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