use clap::{Parser, Subcommand}; /// rustunnel - Cross-platform HTTPS mTLS lab/dev tunneling tool #[derive(Parser, Debug)] #[command(name = "rustunnel", version, about, long_about = None)] pub struct Cli { #[command(subcommand)] pub command: Commands, } #[derive(Subcommand, Debug)] pub enum Commands { /// Start the HTTPS tunnel listener /// /// Binds an HTTPS listener that accepts mTLS connections from connectors. /// Requires certificate and key files, and an auth token for session validation. #[command(trailing_var_arg = true)] Listen { /// Address to bind the HTTPS listener #[arg(long, default_value = "127.0.0.1:4180", value_name = "ADDR:PORT")] listen: String, /// Path to the server certificate file (PEM) #[arg(long, value_name = "PATH")] cert: String, /// Path to the server private key file (PEM) #[arg(long, value_name = "PATH")] key: String, /// Path to the CA certificate file used to validate client certificates (PEM) #[arg(long, value_name = "PATH")] ca_cert: String, /// Auth/session token required from connecting clients #[arg(long, default_value = "", value_name = "TOKEN")] auth_token: String, /// Path to a file containing the auth/session token (alternative to --auth-token) #[arg(long, value_name = "PATH")] auth_token_file: Option, }, /// Connect to the HTTPS tunnel listener and expose a local SOCKS5 proxy /// /// Establishes an mTLS connection to the listener, authenticates, /// then exposes a local SOCKS5 proxy for lab/dev traffic forwarding. #[command(trailing_var_arg = true)] Connect { /// Address of the HTTPS tunnel listener to connect to #[arg(long, default_value = "127.0.0.1:4180", value_name = "ADDR:PORT")] target: String, /// Address to bind the local SOCKS5 proxy listener #[arg(long, default_value = "127.0.0.1:1180", value_name = "ADDR:PORT")] socks: String, /// Path to the client certificate file (PEM) #[arg(long, value_name = "PATH")] cert: String, /// Path to the client private key file (PEM) #[arg(long, value_name = "PATH")] key: String, /// Path to the CA certificate file used to validate the server certificate (PEM) #[arg(long, value_name = "PATH")] ca_cert: String, /// Auth/session token to authenticate with the listener #[arg(long, default_value = "", value_name = "TOKEN")] auth_token: String, /// Path to a file containing the auth/session token (alternative to --auth-token) #[arg(long, value_name = "PATH")] auth_token_file: Option, }, /// Generate local certificate and auth material /// /// Creates self-signed CA, server, and client certificates along with /// an auth token and configuration file in the specified output directory. #[command(trailing_var_arg = true)] Generate { /// Output directory for generated artifacts #[arg(long, short, default_value = ".", value_name = "PATH")] out: String, /// Common name for the CA certificate #[arg(long, default_value = "rustunnel-ca", value_name = "CN")] ca_name: String, /// Common name for the server certificate #[arg(long, default_value = "rustunnel-server", value_name = "CN")] server_name: String, /// Common name for the client certificate #[arg(long, default_value = "rustunnel-client", value_name = "CN")] client_name: String, }, /// Print version information #[command(trailing_var_arg = true)] Version, } /// Parse a "host:port" string into (host, port) with validation. /// Returns an error if the format is invalid or the port is out of range. pub fn parse_host_port(s: &str) -> Result<(String, u16), anyhow::Error> { let parts: Vec<&str> = s.rsplitn(2, ':').collect(); if parts.len() != 2 { return Err(anyhow::anyhow!( "invalid address format '{}': expected 'host:port'", s )); } let port: u16 = parts[0].parse().map_err(|_| { anyhow::anyhow!( "invalid port '{}' in address '{}': must be a number between 1 and 65535", parts[0], s ) })?; if port == 0 { return Err(anyhow::anyhow!( "invalid port 0 in address '{}': port must be between 1 and 65535", s )); } Ok((parts[1].to_string(), port)) } // Remove unused redact_address function #[cfg(test)] mod tests { use super::*; #[test] fn parse_valid_host_port() { let (host, port) = parse_host_port("127.0.0.1:4180").unwrap(); assert_eq!(host, "127.0.0.1"); assert_eq!(port, 4180); } #[test] fn parse_valid_hostname_port() { let (host, port) = parse_host_port("localhost:8080").unwrap(); assert_eq!(host, "localhost"); assert_eq!(port, 8080); } #[test] fn parse_missing_port_fails() { let result = parse_host_port("localhost"); assert!(result.is_err()); assert!( result .unwrap_err() .to_string() .contains("invalid address format") ); } #[test] fn parse_invalid_port_fails() { let result = parse_host_port("localhost:notanumber"); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("invalid port")); } #[test] fn parse_zero_port_fails() { let result = parse_host_port("localhost:0"); assert!(result.is_err()); assert!( result .unwrap_err() .to_string() .contains("port must be between 1 and 65535") ); } #[test] fn parse_port_too_large_fails() { let result = parse_host_port("localhost:70000"); assert!(result.is_err()); } }