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 cors_allow_any: bool,
32 pub log_level: Option<String>,
34 pub version: bool,
36 pub help: bool,
38 pub check_update: bool,
40 pub update: bool,
42 pub no_update_check: bool,
44}
45
46impl Default for Args {
47 fn default() -> Self {
48 Self {
49 host: "127.0.0.1".parse().unwrap(),
50 port: 3000,
51 config: None,
52 api_key: None,
53 no_auth: false,
54 require_auth: false,
55 capabilities: Vec::new(),
56 preset: None,
57 no_rate_limit: false,
58 cors_allow_any: false,
59 log_level: None,
60 version: false,
61 help: false,
62 check_update: false,
63 update: false,
64 no_update_check: false,
65 }
66 }
67}
68
69pub fn parse_args() -> Result<Args, ArgsError> {
71 parse_args_from(std::env::args_os())
72}
73
74pub fn parse_args_from<I>(args: I) -> Result<Args, ArgsError>
76where
77 I: IntoIterator<Item = OsString>,
78{
79 use lexopt::prelude::*;
80
81 let mut result = Args::default();
82 let mut parser = lexopt::Parser::from_iter(args);
83
84 while let Some(arg) = parser.next()? {
85 match arg {
86 Short('h') | Long("help") => {
87 result.help = true;
88 }
89 Short('V') | Long("version") => {
90 result.version = true;
91 }
92 Short('H') | Long("host") => {
93 let value: String = parser.value()?.parse()?;
94 result.host = value
95 .parse()
96 .map_err(|_| ArgsError::InvalidValue("host", value))?;
97 }
98 Short('p') | Long("port") => {
99 let value: String = parser.value()?.parse()?;
100 result.port = value
101 .parse()
102 .map_err(|_| ArgsError::InvalidValue("port", value))?;
103 }
104 Short('c') | Long("config") => {
105 result.config = Some(parser.value()?.parse()?);
106 }
107 Short('k') | Long("api-key") => {
108 result.api_key = Some(parser.value()?.parse()?);
109 }
110 Long("no-auth") => {
111 result.no_auth = true;
112 }
113 Long("require-auth") => {
114 result.require_auth = true;
115 }
116 Long("capabilities") => {
117 let value: String = parser.value()?.parse()?;
119 result.capabilities.extend(
120 value
121 .split(',')
122 .map(|s| s.trim())
123 .filter(|s| !s.is_empty())
124 .map(String::from),
125 );
126 }
127 Long("preset") => {
128 result.preset = Some(parser.value()?.parse()?);
129 }
130 Long("no-rate-limit") => {
131 result.no_rate_limit = true;
132 }
133 Long("cors-allow-any") => {
134 result.cors_allow_any = true;
135 }
136 Short('l') | Long("log-level") => {
137 result.log_level = Some(parser.value()?.parse()?);
138 }
139 #[cfg(feature = "self-update")]
140 Long("check-update") => {
141 result.check_update = true;
142 }
143 #[cfg(feature = "self-update")]
144 Long("update") => {
145 result.update = true;
146 }
147 #[cfg(feature = "self-update")]
148 Long("no-update-check") => {
149 result.no_update_check = true;
150 }
151 Value(val) => {
152 return Err(ArgsError::UnexpectedArgument(val.to_string_lossy().into()));
153 }
154 _ => return Err(arg.unexpected().into()),
155 }
156 }
157
158 Ok(result)
159}
160
161pub fn print_help() {
163 let version = env!("CARGO_PKG_VERSION");
164
165 #[cfg(feature = "self-update")]
167 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";
168 #[cfg(not(feature = "self-update"))]
169 let update_opts = "";
170
171 #[cfg(feature = "self-update")]
172 let update_examples = "\n # Check for updates\n shell-tunnel --check-update\n\n # Self-update to latest version\n shell-tunnel --update\n";
173 #[cfg(not(feature = "self-update"))]
174 let update_examples = "";
175
176 println!(
177 r#"shell-tunnel {version}
178Ultra-lightweight shell tunnel for AI agent integration
179
180USAGE:
181 shell-tunnel [OPTIONS]
182
183OPTIONS:
184 -H, --host <ADDR> Host address to bind [default: 127.0.0.1]
185 -p, --port <PORT> Port to listen on [default: 3000]
186 -c, --config <FILE> Path to configuration file (JSON)
187 -k, --api-key <KEY> API key for authentication
188 -l, --log-level <LVL> Log level (error, warn, info, debug, trace)
189 --no-auth Disable authentication
190 --require-auth Require auth, auto-generating an API key if none given
191 --capabilities <C> Scope issued token(s): comma-separated capabilities
192 (e.g. exec,session.read). Default: full-control
193 --preset <NAME> Scope issued token(s) by role preset
194 (operator | read-only | full-control)
195 --no-rate-limit Disable rate limiting
196 --cors-allow-any Allow any CORS origin (opt-in; for browser UIs)
197{update_opts} -h, --help Print help
198 -V, --version Print version
199
200ENVIRONMENT VARIABLES:
201 SHELL_TUNNEL_HOST Host address (overrides config)
202 SHELL_TUNNEL_PORT Port number (overrides config)
203 SHELL_TUNNEL_API_KEY API key (overrides config)
204 SHELL_TUNNEL_LOG_LEVEL Log level (overrides config)
205 RUST_LOG Alternative log level setting
206
207EXAMPLES:
208 # Start with defaults (localhost:3000, no auth)
209 shell-tunnel
210
211 # Start on all interfaces with API key
212 shell-tunnel -H 0.0.0.0 -p 8080 -k my-secret-key
213
214 # Start with config file
215 shell-tunnel -c /etc/shell-tunnel/config.json
216
217 # Development mode (no security)
218 shell-tunnel --no-auth --no-rate-limit
219
220 # Issue a fine-grained, read-only token
221 shell-tunnel -k readonly-key --preset read-only
222
223 # Issue a token scoped to specific capabilities
224 shell-tunnel -k agent-key --capabilities exec,session.read
225{update_examples}"#
226 );
227}
228
229pub fn print_version() {
231 println!("shell-tunnel {}", env!("CARGO_PKG_VERSION"));
232}
233
234#[derive(Debug)]
236pub enum ArgsError {
237 Lexopt(lexopt::Error),
239 InvalidValue(&'static str, String),
241 UnexpectedArgument(String),
243}
244
245impl std::fmt::Display for ArgsError {
246 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
247 match self {
248 Self::Lexopt(e) => write!(f, "{}", e),
249 Self::InvalidValue(name, value) => {
250 write!(f, "invalid value for --{}: '{}'", name, value)
251 }
252 Self::UnexpectedArgument(arg) => {
253 write!(f, "unexpected argument: '{}'", arg)
254 }
255 }
256 }
257}
258
259impl std::error::Error for ArgsError {}
260
261impl From<lexopt::Error> for ArgsError {
262 fn from(e: lexopt::Error) -> Self {
263 Self::Lexopt(e)
264 }
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270
271 fn args(args: &[&str]) -> Vec<OsString> {
272 std::iter::once("shell-tunnel")
273 .chain(args.iter().copied())
274 .map(OsString::from)
275 .collect()
276 }
277
278 #[test]
279 fn test_default_args() {
280 let result = parse_args_from(args(&[])).unwrap();
281 assert_eq!(result.host.to_string(), "127.0.0.1");
282 assert_eq!(result.port, 3000);
283 assert!(!result.no_auth);
284 }
285
286 #[test]
287 fn test_host_port() {
288 let result = parse_args_from(args(&["-H", "0.0.0.0", "-p", "8080"])).unwrap();
289 assert_eq!(result.host.to_string(), "0.0.0.0");
290 assert_eq!(result.port, 8080);
291 }
292
293 #[test]
294 fn test_long_options() {
295 let result = parse_args_from(args(&["--host", "192.168.1.1", "--port", "9000"])).unwrap();
296 assert_eq!(result.host.to_string(), "192.168.1.1");
297 assert_eq!(result.port, 9000);
298 }
299
300 #[test]
301 fn test_api_key() {
302 let result = parse_args_from(args(&["-k", "my-secret"])).unwrap();
303 assert_eq!(result.api_key, Some("my-secret".to_string()));
304 }
305
306 #[test]
307 fn test_config_file() {
308 let result = parse_args_from(args(&["-c", "/etc/config.json"])).unwrap();
309 assert_eq!(result.config, Some(PathBuf::from("/etc/config.json")));
310 }
311
312 #[test]
313 fn test_no_auth() {
314 let result = parse_args_from(args(&["--no-auth"])).unwrap();
315 assert!(result.no_auth);
316 }
317
318 #[test]
319 fn test_require_auth() {
320 let result = parse_args_from(args(&["--require-auth"])).unwrap();
321 assert!(result.require_auth);
322 assert!(!Args::default().require_auth);
323 }
324
325 #[test]
326 fn test_no_rate_limit() {
327 let result = parse_args_from(args(&["--no-rate-limit"])).unwrap();
328 assert!(result.no_rate_limit);
329 }
330
331 #[test]
332 fn test_capabilities_csv() {
333 let result = parse_args_from(args(&["--capabilities", "exec,session.read"])).unwrap();
334 assert_eq!(result.capabilities, vec!["exec", "session.read"]);
335 assert!(Args::default().capabilities.is_empty());
336 }
337
338 #[test]
339 fn test_capabilities_trims_and_ignores_blanks() {
340 let result = parse_args_from(args(&["--capabilities", " exec , , session.read "])).unwrap();
341 assert_eq!(result.capabilities, vec!["exec", "session.read"]);
342 }
343
344 #[test]
345 fn test_capabilities_repeated_accumulate() {
346 let result = parse_args_from(args(&[
347 "--capabilities",
348 "exec",
349 "--capabilities",
350 "session.read,session.manage",
351 ]))
352 .unwrap();
353 assert_eq!(
354 result.capabilities,
355 vec!["exec", "session.read", "session.manage"]
356 );
357 }
358
359 #[test]
360 fn test_preset() {
361 let result = parse_args_from(args(&["--preset", "operator"])).unwrap();
362 assert_eq!(result.preset, Some("operator".to_string()));
363 assert!(Args::default().preset.is_none());
364 }
365
366 #[test]
367 fn test_help_flag() {
368 let result = parse_args_from(args(&["-h"])).unwrap();
369 assert!(result.help);
370
371 let result = parse_args_from(args(&["--help"])).unwrap();
372 assert!(result.help);
373 }
374
375 #[test]
376 fn test_version_flag() {
377 let result = parse_args_from(args(&["-V"])).unwrap();
378 assert!(result.version);
379
380 let result = parse_args_from(args(&["--version"])).unwrap();
381 assert!(result.version);
382 }
383
384 #[test]
385 fn test_log_level() {
386 let result = parse_args_from(args(&["-l", "debug"])).unwrap();
387 assert_eq!(result.log_level, Some("debug".to_string()));
388 }
389
390 #[test]
391 fn test_invalid_port() {
392 let result = parse_args_from(args(&["-p", "invalid"]));
393 assert!(result.is_err());
394 }
395
396 #[test]
397 fn test_invalid_host() {
398 let result = parse_args_from(args(&["-H", "not-an-ip"]));
399 assert!(result.is_err());
400 }
401
402 #[test]
403 fn test_combined_options() {
404 let result = parse_args_from(args(&[
405 "-H",
406 "0.0.0.0",
407 "-p",
408 "8080",
409 "-k",
410 "secret",
411 "-l",
412 "debug",
413 "--no-rate-limit",
414 ]))
415 .unwrap();
416
417 assert_eq!(result.host.to_string(), "0.0.0.0");
418 assert_eq!(result.port, 8080);
419 assert_eq!(result.api_key, Some("secret".to_string()));
420 assert_eq!(result.log_level, Some("debug".to_string()));
421 assert!(result.no_rate_limit);
422 assert!(!result.no_auth);
423 }
424}