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