Skip to main content

shell_tunnel/
cli.rs

1//! Command-line interface for shell-tunnel.
2//!
3//! Uses lexopt for minimal binary size overhead (~34KB).
4
5use std::ffi::OsString;
6use std::net::IpAddr;
7use std::path::PathBuf;
8
9/// Command-line arguments.
10#[derive(Debug, Clone)]
11pub struct Args {
12    /// Host address to bind to.
13    pub host: IpAddr,
14    /// Port to listen on.
15    pub port: u16,
16    /// Path to configuration file.
17    pub config: Option<PathBuf>,
18    /// API key for authentication (overrides config file).
19    pub api_key: Option<String>,
20    /// Disable authentication.
21    pub no_auth: bool,
22    /// Require authentication, auto-generating an API key if none is provided.
23    pub require_auth: bool,
24    /// Capability strings scoping the issued token(s) (empty = full-control).
25    pub capabilities: Vec<String>,
26    /// Role preset scoping the issued token(s) (operator/read-only/full-control).
27    pub preset: Option<String>,
28    /// Disable rate limiting.
29    pub no_rate_limit: bool,
30    /// Allow any CORS origin (permissive; opt-in for browser UIs).
31    pub cors_allow_any: bool,
32    /// Log level (error, warn, info, debug, trace).
33    pub log_level: Option<String>,
34    /// Show version and exit.
35    pub version: bool,
36    /// Show help and exit.
37    pub help: bool,
38    /// Check for updates and exit.
39    pub check_update: bool,
40    /// Perform self-update and exit.
41    pub update: bool,
42    /// Disable automatic update check on startup.
43    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
69/// Parse command-line arguments.
70pub fn parse_args() -> Result<Args, ArgsError> {
71    parse_args_from(std::env::args_os())
72}
73
74/// Parse arguments from an iterator (for testing).
75pub 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                // Comma-separated; may be repeated. Accumulate non-empty entries.
118                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
161/// Print help message.
162pub fn print_help() {
163    let version = env!("CARGO_PKG_VERSION");
164
165    // Update flags exist only when compiled with the `self-update` feature.
166    #[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
229/// Print version.
230pub fn print_version() {
231    println!("shell-tunnel {}", env!("CARGO_PKG_VERSION"));
232}
233
234/// Argument parsing errors.
235#[derive(Debug)]
236pub enum ArgsError {
237    /// Lexopt parsing error.
238    Lexopt(lexopt::Error),
239    /// Invalid argument value.
240    InvalidValue(&'static str, String),
241    /// Unexpected positional argument.
242    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}