feat: project skeleton, CLI surface, generate command, secret redaction, and CI matrix
- Create Rust binary crate with clap CLI (listen, connect, generate, version) - Implement generate command: CA/server/client certs, auth token, config.json - Strict secret redaction: Redacted type, PEM/token/config redaction utilities - Configurable addresses and ports with validation (fails before sockets open) - GitHub Actions CI matrix: Windows/Linux/macOS with fmt, clippy, test, build, release-build, and smoke check jobs - 39 unit tests covering CLI parsing, redaction, config, and credential generation - Cross-platform safe paths and clean-room implementation
This commit is contained in:
+181
@@ -0,0 +1,181 @@
|
||||
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, value_name = "TOKEN")]
|
||||
auth_token: String,
|
||||
},
|
||||
|
||||
/// 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, value_name = "TOKEN")]
|
||||
auth_token: String,
|
||||
},
|
||||
|
||||
/// 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user