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